diff options
Diffstat (limited to 'gemini')
| -rw-r--r-- | gemini/handler.go | 29 | ||||
| -rw-r--r-- | gemini/request.go | 50 | ||||
| -rw-r--r-- | gemini/request_test.go | 86 | ||||
| -rw-r--r-- | gemini/response.go | 308 | ||||
| -rw-r--r-- | gemini/response_test.go | 151 | ||||
| -rw-r--r-- | gemini/serve.go | 89 | ||||
| -rw-r--r-- | gemini/tls.go | 16 | 
7 files changed, 729 insertions, 0 deletions
| diff --git a/gemini/handler.go b/gemini/handler.go new file mode 100644 index 0000000..ded77a5 --- /dev/null +++ b/gemini/handler.go @@ -0,0 +1,29 @@ +package gemini + +import "context" + +// Handler is a function which can turn a gemini request into a gemini response. +// +// A Handler MUST NOT return a nil response. Errors should be returned in the form +// of error responses (4x, 5x, 6x response status). If the Handler should not be +// responsible for the requested resource it can return a "51 Not Found" response. +type Handler func(context.Context, *Request) *Response + +// Middleware is a handle decorator. +// +// It returns a handler which may call the passed-in handler or not, or may +// transform the request or response in some way. +type Middleware func(Handler) Handler + +func Fallthrough(handlers ...Handler) Handler { +	return func(ctx context.Context, req *Request) *Response { +		for _, handler := range handlers { +			response := handler(ctx, req) +			if response.Status != StatusNotFound { +				return response +			} +		} + +		return NotFound("Resource does not exist.") +	} +} diff --git a/gemini/request.go b/gemini/request.go new file mode 100644 index 0000000..248ce67 --- /dev/null +++ b/gemini/request.go @@ -0,0 +1,50 @@ +package gemini + +import ( +	"bufio" +	"crypto/tls" +	"errors" +	"io" +	"net/url" +) + +// InvalidRequestLineEnding indicates that a gemini request didn't end with "\r\n". +var InvalidRequestLineEnding = errors.New("invalid request line ending") + +// Request represents a request over the gemini protocol. +type Request struct { +	*url.URL + +	TLSState *tls.ConnectionState +} + +// ParseRequest parses a single gemini request from a reader. +func ParseRequest(rdr io.Reader) (*Request, error) { +	line, err := bufio.NewReader(rdr).ReadString('\n') +	if err != io.EOF && err != nil { +		return nil, err +	} + +	if len(line) < 2 || line[len(line)-2:] != "\r\n" { +		return nil, InvalidRequestLineEnding +	} + +	u, err := url.Parse(line[:len(line)-2]) +	if err != nil { +		return nil, err +	} + +	if u.Scheme == "" { +		u.Scheme = "gemini" +	} + +	return &Request{URL: u}, nil +} + +// UnescapedQuery performs %XX unescaping on the URL query segment. +// +// Like URL.Query(), it silently drops malformed %-encoded sequences. +func (req Request) UnescapedQuery() string { +	unescaped, _ := url.QueryUnescape(req.RawQuery) +	return unescaped +} diff --git a/gemini/request_test.go b/gemini/request_test.go new file mode 100644 index 0000000..1da24f7 --- /dev/null +++ b/gemini/request_test.go @@ -0,0 +1,86 @@ +package gemini_test + +import ( +	"bytes" +	"testing" + +	"tildegit.org/tjp/gus/gemini" +) + +func TestParseRequest(t *testing.T) { +	table := []struct { +		input    string +		scheme   string +		host     string +		path     string +		query    string +		fragment string +		err      error +	}{ +		{ +			input:    "gemini://foo.com/bar?baz#qux\r\n", +			scheme:   "gemini", +			host:     "foo.com", +			path:     "/bar", +			query:    "baz", +			fragment: "qux", +			err:      nil, +		}, +		{ +			input:    "//foo.com/path\r\n", +			scheme:   "gemini", +			host:     "foo.com", +			path:     "/path", +			query:    "", +			fragment: "", +			err:      nil, +		}, +		{ +			input:    "/path\r\n", +			scheme:   "gemini", +			host:     "", +			path:     "/path", +			query:    "", +			fragment: "", +			err:      nil, +		}, +		{ +			input:    "gemini://invalid.com/line/ending", +			scheme:   "", +			host:     "", +			path:     "", +			query:    "", +			fragment: "", +			err:      gemini.InvalidRequestLineEnding, +		}, +	} + +	for _, test := range table { +		t.Run(test.input, func(t *testing.T) { +			req, err := gemini.ParseRequest(bytes.NewBufferString(test.input)) +			if err != test.err { +				t.Fatalf("expected error %q, got %q", test.err, err) +			} + +			if err != nil { +				return +			} + +			if req.Scheme != test.scheme { +				t.Errorf("expected scheme %q, got %q", test.scheme, req.Scheme) +			} +			if req.Host != test.host { +				t.Errorf("expected host %q, got %q", test.host, req.Host) +			} +			if req.Path != test.path { +				t.Errorf("expected path %q, got %q", test.path, req.Path) +			} +			if req.RawQuery != test.query { +				t.Errorf("expected query %q, got %q", test.query, req.RawQuery) +			} +			if req.Fragment != test.fragment { +				t.Errorf("expected fragment %q, got %q", test.fragment, req.Fragment) +			} +		}) +	} +} diff --git a/gemini/response.go b/gemini/response.go new file mode 100644 index 0000000..90340a5 --- /dev/null +++ b/gemini/response.go @@ -0,0 +1,308 @@ +package gemini + +import ( +	"bytes" +	"io" +	"strconv" +) + +// StatusCategory represents the various types of responses. +type StatusCategory int + +const ( +	// StatusCategoryInput is for responses which request additional input. +	// +	// The META line will be the prompt to display to the user. +	StatusCategoryInput StatusCategory = iota*10 + 10 +	// StatusCategorySuccess is for successful responses. +	// +	// The META line will be the resource's mime type. +	// This is the only response status which indicates the presence of a response body, +	// and it will contain the resource itself. +	StatusCategorySuccess +	// StatusCategoryRedirect is for responses which direct the client to an alternative URL. +	// +	// The META line will contain the new URL the client should try. +	StatusCategoryRedirect +	// StatusCategoryTemporaryFailure is for responses which indicate a transient server-side failure. +	// +	// The META line may contain a line with more information about the error. +	StatusCategoryTemporaryFailure +	// StatusCategoryPermanentFailure is for permanent failure responses. +	// +	// The META line may contain a line with more information about the error. +	StatusCategoryPermanentFailure +	// StatusCategoryCertificateRequired indicates client certificate related issues. +	// +	// The META line may contain a line with more information about the error. +	StatusCategoryCertificateRequired +) + +// Status is the integer status code of a gemini response. +type Status int + +const ( +	// StatusInput indicates a required query parameter at the requested URL. +	StatusInput Status = Status(StatusCategoryInput) + iota +	// StatusSensitiveInput indicates a sensitive query parameter is required. +	StatusSensitiveInput +) + +const ( +	// StatusSuccess is a successful response. +	StatusSuccess = Status(StatusCategorySuccess) + iota +) + +const ( +	// StatusTemporaryRedirect indicates a temporary redirect to another URL. +	StatusTemporaryRedirect = Status(StatusCategoryRedirect) + iota +	// StatusPermanentRedirect indicates that the resource should always be requested at the new URL. +	StatusPermanentRedirect +) + +const ( +	// StatusTemporaryFailure indicates that the request failed and there is no response body. +	StatusTemporaryFailure = Status(StatusCategoryTemporaryFailure) + iota +	// StatusServerUnavailable occurs when the server is unavailable due to overload or maintenance. +	StatusServerUnavailable +	// StatusCGIError is the result of a failure of a CGI script. +	StatusCGIError +	// StatusProxyError indicates that the server is acting as a proxy and the outbound request failed. +	StatusProxyError +	// StatusSlowDown tells the client that rate limiting is in effect. +	// +	// Unlike other statuses in this category, the META line is an integer indicating how +	// many more seconds the client must wait before sending another request. +	StatusSlowDown +) + +const ( +	// StatusPermanentFailure is a server failure which should be expected to continue indefinitely. +	StatusPermanentFailure = Status(StatusCategoryPermanentFailure) + iota +	// StatusNotFound means the resource doesn't exist but it may in the future. +	StatusNotFound +	// StatusGone occurs when a resource will not be available any longer. +	StatusGone +	// StatusProxyRequestRefused means the server is unwilling to act as a proxy for the resource. +	StatusProxyRequestRefused +	// StatusBadRequest indicates that the request was malformed somehow. +	StatusBadRequest = Status(StatusCategoryPermanentFailure) + 9 +) + +const ( +	// StatusClientCertificateRequired is returned when a certificate was required but not provided. +	StatusClientCertificateRequired = Status(StatusCategoryCertificateRequired) + iota +	// StatusCertificateNotAuthorized means the certificate doesn't grant access to the requested resource. +	StatusCertificateNotAuthorized +	// StatusCertificateNotValid means the provided client certificate is invalid. +	StatusCertificateNotValid +) + +// StatusCategory returns the category a specific status belongs to. +func (s Status) Category() StatusCategory { +	return StatusCategory(s / 10) +} + +// Response contains everything in a gemini protocol response. +type Response struct { +	// Status is the status code of the response. +	Status Status + +	// Meta is the status-specific line of additional information. +	Meta string + +	// Body is the response body, if any. +	Body io.Reader + +	reader io.Reader +} + +// Input builds an input-prompting response. +func Input(prompt string) *Response { +	return &Response{ +		Status: StatusInput, +		Meta:   prompt, +	} +} + +// SensitiveInput builds a password-prompting response. +func SensitiveInput(prompt string) *Response { +	return &Response{ +		Status: StatusSensitiveInput, +		Meta:   prompt, +	} +} + +// Success builds a success response with resource body. +func Success(mediatype string, body io.Reader) *Response { +	return &Response{ +		Status: StatusSuccess, +		Meta:   mediatype, +		Body:   body, +	} +} + +// Redirect builds a redirect response. +func Redirect(url string) *Response { +	return &Response{ +		Status: StatusTemporaryRedirect, +		Meta:   url, +	} +} + +// PermanentRedirect builds a response with a permanent redirect. +func PermanentRedirect(url string) *Response { +	return &Response{ +		Status: StatusPermanentRedirect, +		Meta:   url, +	} +} + +// Failure builds a temporary failure response from an error. +func Failure(err error) *Response { +	return &Response{ +		Status: StatusTemporaryFailure, +		Meta:   err.Error(), +	} +} + +// Unavailable build a "server unavailable" response. +func Unavailable(msg string) *Response { +	return &Response{ +		Status: StatusServerUnavailable, +		Meta:   msg, +	} +} + +// CGIError builds a "cgi error" response. +func CGIError(err string) *Response { +	return &Response{ +		Status: StatusCGIError, +		Meta:   err, +	} +} + +// ProxyError builds a proxy error response. +func ProxyError(msg string) *Response { +	return &Response{ +		Status: StatusProxyError, +		Meta:   msg, +	} +} + +// SlowDown builds a "slow down" response with the number of seconds until the resource is available. +func SlowDown(seconds int) *Response { +	return &Response{ +		Status: StatusSlowDown, +		Meta:   strconv.Itoa(seconds), +	} +} + +// PermanentFailure builds a "permanent failure" from an error. +func PermanentFailure(err error) *Response { +	return &Response{ +		Status: StatusPermanentFailure, +		Meta:   err.Error(), +	} +} + +// NotFound builds a "resource not found" response. +func NotFound(msg string) *Response { +	return &Response{ +		Status: StatusNotFound, +		Meta:   msg, +	} +} + +// Gone builds a "resource gone" response. +func Gone(msg string) *Response { +	return &Response{ +		Status: StatusGone, +		Meta:   msg, +	} +} + +// RefuseProxy builds a "proxy request refused" response. +func RefuseProxy(msg string) *Response { +	return &Response{ +		Status: StatusProxyRequestRefused, +		Meta:   msg, +	} +} + +// BadRequest builds a "bad request" response. +func BadRequest(msg string) *Response { +	return &Response{ +		Status: StatusBadRequest, +		Meta:   msg, +	} +} + +// RequireCert builds a "client certificate required" response. +func RequireCert(msg string) *Response { +	return &Response{ +		Status: StatusClientCertificateRequired, +		Meta:   msg, +	} +} + +// CertAuthFailure builds a "certificate not authorized" response. +func CertAuthFailure(msg string) *Response { +	return &Response{ +		Status: StatusCertificateNotAuthorized, +		Meta:   msg, +	} +} + +// CertInvalid builds a "client certificate not valid" response. +func CertInvalid(msg string) *Response { +	return &Response{ +		Status: StatusCertificateNotValid, +		Meta:   msg, +	} +} + +// Read implements io.Reader for Response. +func (r *Response) Read(b []byte) (int, error) { +	r.ensureReader() +	return r.reader.Read(b) +} + +// WriteTo implements io.WriterTo for Response. +func (r *Response) WriteTo(dst io.Writer) (int64, error) { +	r.ensureReader() +	return r.reader.(io.WriterTo).WriteTo(dst) +} + +// Close implements io.Closer and ensures the body gets closed. +func (r *Response) Close() error { +	if r != nil { +		if cl, ok := r.Body.(io.Closer); ok { +			return cl.Close() +		} +	} +	return nil +} + +func (r *Response) ensureReader() { +	if r.reader != nil { +		return +	} + +	hdr := bytes.NewBuffer(r.headerLine()) +	if r.Body != nil { +		r.reader = io.MultiReader(hdr, r.Body) +	} else { +		r.reader = hdr +	} +} + +func (r Response) headerLine() []byte { +	buf := make([]byte, len(r.Meta)+5) +	_ = strconv.AppendInt(buf[:0], int64(r.Status), 10) +	buf[2] = ' ' +	copy(buf[3:], r.Meta) +	buf[len(buf)-2] = '\r' +	buf[len(buf)-1] = '\n' +	return buf +} diff --git a/gemini/response_test.go b/gemini/response_test.go new file mode 100644 index 0000000..3e1f41f --- /dev/null +++ b/gemini/response_test.go @@ -0,0 +1,151 @@ +package gemini_test + +import ( +	"bytes" +	"errors" +	"io" +	"testing" + +	"tildegit.org/tjp/gus/gemini" +) + +func TestBuildResponses(t *testing.T) { +	table := []struct { +		name     string +		response *gemini.Response +		status   gemini.Status +		meta     string +		body     string +	}{ +		{ +			name:     "input response", +			response: gemini.Input("prompt here"), +			status:   gemini.StatusInput, +			meta:     "prompt here", +		}, +		{ +			name:     "sensitive input response", +			response: gemini.SensitiveInput("password please"), +			status:   gemini.StatusSensitiveInput, +			meta:     "password please", +		}, +		{ +			name:     "success response", +			response: gemini.Success("text/gemini", bytes.NewBufferString("body text here")), +			status:   gemini.StatusSuccess, +			meta:     "text/gemini", +			body:     "body text here", +		}, +		{ +			name:     "temporary redirect", +			response: gemini.Redirect("/foo/bar"), +			status:   gemini.StatusTemporaryRedirect, +			meta:     "/foo/bar", +		}, +		{ +			name:     "permanent redirect", +			response: gemini.PermanentRedirect("/baz/qux"), +			status:   gemini.StatusPermanentRedirect, +			meta:     "/baz/qux", +		}, +		{ +			name:     "fail response", +			response: gemini.Failure(errors.New("a failure")), +			status:   gemini.StatusTemporaryFailure, +			meta:     "a failure", +		}, +		{ +			name:     "server unavailable", +			response: gemini.Unavailable("server unavailable"), +			status:   gemini.StatusServerUnavailable, +			meta:     "server unavailable", +		}, +		{ +			name:     "cgi error", +			response: gemini.CGIError("some cgi error msg"), +			status:   gemini.StatusCGIError, +			meta:     "some cgi error msg", +		}, +		{ +			name:     "proxy error", +			response: gemini.ProxyError("upstream's full"), +			status:   gemini.StatusProxyError, +			meta:     "upstream's full", +		}, +		{ +			name:     "rate limiting", +			response: gemini.SlowDown(15), +			status:   gemini.StatusSlowDown, +			meta:     "15", +		}, +		{ +			name:     "permanent failure", +			response: gemini.PermanentFailure(errors.New("wut r u doin")), +			status:   gemini.StatusPermanentFailure, +			meta:     "wut r u doin", +		}, +		{ +			name:     "not found", +			response: gemini.NotFound("nope"), +			status:   gemini.StatusNotFound, +			meta:     "nope", +		}, +		{ +			name:     "gone", +			response: gemini.Gone("all out of that"), +			status:   gemini.StatusGone, +			meta:     "all out of that", +		}, +		{ +			name:     "refuse proxy", +			response: gemini.RefuseProxy("no I don't think I will"), +			status:   gemini.StatusProxyRequestRefused, +			meta:     "no I don't think I will", +		}, +		{ +			name:     "bad request", +			response: gemini.BadRequest("that don't make no sense"), +			status:   gemini.StatusBadRequest, +			meta:     "that don't make no sense", +		}, +		{ +			name:     "require cert", +			response: gemini.RequireCert("cert required"), +			status:   gemini.StatusClientCertificateRequired, +			meta:     "cert required", +		}, +		{ +			name:     "cert auth failure", +			response: gemini.CertAuthFailure("you can't see that"), +			status:   gemini.StatusCertificateNotAuthorized, +			meta:     "you can't see that", +		}, +		{ +			name:     "invalid cert", +			response: gemini.CertInvalid("bad cert dude"), +			status:   gemini.StatusCertificateNotValid, +			meta:     "bad cert dude", +		}, +	} + +	for _, test := range table { +		t.Run(test.name, func(t *testing.T) { +			if test.response.Status != test.status { +				t.Errorf("expected status %d, got %d", test.status, test.response.Status) +			} +			if test.response.Meta != test.meta { +				t.Errorf("expected meta %q, got %q", test.meta, test.response.Meta) +			} + +			responseBytes, err := io.ReadAll(test.response) +			if err != nil { +				t.Fatalf("error reading response body: %q", err.Error()) +			} + +			body := string(bytes.SplitN(responseBytes, []byte("\r\n"), 2)[1]) +			if body != test.body { +				t.Errorf("expected body %q, got %q", test.body, body) +			} +		}) +	} +} diff --git a/gemini/serve.go b/gemini/serve.go new file mode 100644 index 0000000..d439472 --- /dev/null +++ b/gemini/serve.go @@ -0,0 +1,89 @@ +package gemini + +import ( +	"context" +	"crypto/tls" +	"io" +	"net" +	"sync" +) + +type Server struct { +	ctx      context.Context +	cancel   context.CancelFunc +	wg       *sync.WaitGroup +	listener net.Listener +	handler  Handler +} + +func NewServer(ctx context.Context, tlsConfig *tls.Config, listener net.Listener, handler Handler) *Server { +	ctx, cancel := context.WithCancel(ctx) + +	s := &Server{ +		ctx:      ctx, +		cancel:   cancel, +		wg:       &sync.WaitGroup{}, +		listener: tls.NewListener(listener, tlsConfig), +		handler:  handler, +	} +	go s.propagateCancel() + +	return s +} + +func (s *Server) Close() { +	s.cancel() +	s.wg.Wait() +} + +func (s *Server) Serve() { +	s.wg.Add(1) +	defer s.wg.Done() + +	for { +		conn, err := s.listener.Accept() +		if err != nil { +			return +		} + +		s.wg.Add(1) +		go s.handleConn(conn) +	} +} + +func (s *Server) handleConn(conn net.Conn) { +	defer s.wg.Done() +	defer conn.Close() + +	req, err := ParseRequest(conn) +	if tlsconn, ok := conn.(*tls.Conn); req != nil && ok { +		state := tlsconn.ConnectionState() +		req.TLSState = &state +	} + +	var resp *Response +	if err == nil { +		resp = s.handler(s.ctx, req) +	} else { +		resp = BadRequest(err.Error()) +	} +	defer resp.Close() + +	_, _ = io.Copy(conn, resp) +} + +func (s *Server) propagateCancel() { +	go func() { +		<-s.ctx.Done() +		_ = s.listener.Close() +	}() +} + +func (s *Server) closed() bool { +	select { +	case <-s.ctx.Done(): +		return true +	default: +		return false +	} +} diff --git a/gemini/tls.go b/gemini/tls.go new file mode 100644 index 0000000..3cdf93b --- /dev/null +++ b/gemini/tls.go @@ -0,0 +1,16 @@ +package gemini + +import "crypto/tls" + +func FileTLS(certfile string, keyfile string) (*tls.Config, error) { +	cert, err := tls.LoadX509KeyPair(certfile, keyfile) +	if err != nil { +		return nil, err +	} + +	return &tls.Config{ +		Certificates: []tls.Certificate{cert}, +		MinVersion:   tls.VersionTLS12, +		ClientAuth:   tls.RequestClientCert, +	}, nil +} | 
