From 257b8076756fa25e3b07d6d85e76874d1fe33268 Mon Sep 17 00:00:00 2001 From: adnano Date: Mon, 21 Sep 2020 15:48:42 -0400 Subject: [PATCH] Initial commit --- README.md | 100 +++++++++++++++++++++++++++++ client.go | 135 +++++++++++++++++++++++++++++++++++++++ go.mod | 3 + server.go | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 README.md create mode 100644 client.go create mode 100644 go.mod create mode 100644 server.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..7800c2f --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# go-gemini + +`go-gemini` implements the [Gemini protocol](https://gemini.circumlunar.space) in +Go. + +It aims to provide an interface similar to that of `net/http` to make it easy +to develop Gemini clients and servers. + +## Usage + +First generate TLS keys for your server to use. + +```sh +openssl genrsa -out server.key 2048 +openssl ecparam -genkey -name secp384r1 -out server.key +openssl req -new -x509 -sha256 -key server.key -out server.crt -days 3650 +``` + +Next, import and use `go-gemini`. Here is a simple server: + +```go +import ( + "git.sr.ht/~adnano/go-gemini" +) + +func main() { + config := &tls.Config{} + cert, err := tls.LoadX509KeyPair("server.crt", "server.key") + if err != nil { + log.Fatal(err) + } + config.Certificates = append(config.Certificates, cert) + + mux := &gemini.Mux{} + mux.HandleFunc("/", func(url *url.URL) *gemini.Response { + return &gemini.Response{ + Status: gemini.StatusSuccess, + Meta: "text/gemini", + Body: []byte("You requested " + url.String()), + } + }) + + server := gemini.Server{ + TLSConfig: config, + Handler: mux, + } + server.ListenAndServe() +} +``` + +And a simple client: + +```go +import ( + "git.sr.ht/~adnano/go-gemini" +) + +var client gemini.Client + +func makeRequest(url string) { + resp, err := client.Get(url) + if err != nil { + log.Fatal(err) + } + + fmt.Println("Status code:", resp.Status) + fmt.Println("Meta:", resp.Meta) + + switch resp.Status / 10 { + case gemini.StatusClassInput: + scanner := bufio.NewScanner(os.Stdin) + fmt.Printf("%s: ", resp.Meta) + scanner.Scan() + query := scanner.Text() + makeRequest(url + "?" + query) + return + case gemini.StatusClassSuccess: + fmt.Print("Body:\n", string(resp.Body)) + case gemini.StatusClassRedirect: + log.Print("Redirecting to ", resp.Meta) + makeRequest(resp.Meta) + return + case gemini.StatusClassTemporaryFailure: + log.Fatal("Temporary failure") + case gemini.StatusClassPermanentFailure: + log.Fatal("Permanent failure") + case gemini.StatusClassClientCertificateRequired: + log.Fatal("Client certificate required") + default: + log.Fatal("Protocol error: invalid status code") + } +} + +func main() { + if len(os.Args) < 2 { + log.Fatalf("usage: %s gemini://...", os.Args[0]) + } + makeRequest(os.Args[1]) +} +``` diff --git a/client.go b/client.go new file mode 100644 index 0000000..b44c8fb --- /dev/null +++ b/client.go @@ -0,0 +1,135 @@ +package gemini + +import ( + "bufio" + "crypto/tls" + "errors" + "io/ioutil" + "net/url" + "strconv" + "strings" +) + +var ( + ProtocolError = errors.New("Protocol error") +) + +// Client is a Gemini client. +type Client struct { + TLSConfig tls.Config +} + +func (c *Client) Get(url string) (*Response, error) { + req, err := NewRequest(url) + if err != nil { + return nil, err + } + return c.Do(req) +} + +func (c *Client) GetProxy(host, url string) (*Response, error) { + req, err := NewProxyRequest(host, url) + if err != nil { + return nil, err + } + return c.Do(req) +} + +// Request is a Gemini request. +type Request struct { + Host string // host or host:port + URL *url.URL // The URL to request +} + +// NewRequest returns a new request. The host is inferred from the provided url. +func NewRequest(rawurl string) (*Request, error) { + u, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + + // Ignore UserInfo if present + u.User = nil + + return &Request{ + Host: u.Host, + URL: u, + }, nil +} + +// NewProxyRequest makes a new request using the provided host. +func NewProxyRequest(host, rawurl string) (*Request, error) { + u, err := url.Parse(rawurl) + if err != nil { + return nil, err + } + + // Ignore UserInfo if present + u.User = nil + + return &Request{ + Host: host, + URL: u, + }, nil +} + +func (c *Client) Do(req *Request) (*Response, error) { + host := req.Host + if strings.LastIndex(host, ":") == -1 { + // The default port is 1965 + host += ":1965" + } + + config := &tls.Config{ + // Allow self-signed certificates + // TODO: Trust on first use + InsecureSkipVerify: true, + } + conn, err := tls.Dial("tcp", host, config) + if err != nil { + return nil, err + } + defer conn.Close() + + // Write the request + request := req.URL.String() + "\r\n" + if _, err := conn.Write([]byte(request)); err != nil { + return nil, err + } + + // Read the response header + code := make([]byte, 2) + if _, err := conn.Read(code); err != nil { + return nil, err + } + status, err := strconv.Atoi(string(code)) + if err != nil { + return nil, err + } + + // Read one space + space := make([]byte, 1) + if _, err := conn.Read(space); err != nil { + return nil, err + } + if space[0] != ' ' { + return nil, ProtocolError + } + + // Read the meta + scanner := bufio.NewScanner(conn) + scanner.Scan() + meta := scanner.Text() + + // Read the response body + body, err := ioutil.ReadAll(conn) + if err != nil { + return nil, err + } + + return &Response{ + Status: status, + Meta: meta, + Body: body, + }, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f850117 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.sr.ht/~adnano/go-gemini + +go 1.15 diff --git a/server.go b/server.go new file mode 100644 index 0000000..24c6a15 --- /dev/null +++ b/server.go @@ -0,0 +1,185 @@ +package gemini + +import ( + "bufio" + "crypto/tls" + "io" + "net" + "net/url" + "strconv" + "strings" +) + +// Status codes. +const ( + StatusInput = 10 + StatusSensitiveInput = 11 + StatusSuccess = 20 + StatusRedirectTemporary = 30 + StatusRedirectPermanent = 31 + StatusTemporaryFailure = 40 + StatusServerUnavailable = 41 + StatusCGIError = 42 + StatusProxyError = 43 + StatusSlowDown = 44 + StatusPermanentFailure = 50 + StatusNotFound = 51 + StatusGone = 52 + StatusProxyRequestRefused = 53 + StatusBadRequest = 59 + StatusClientCertificateRequired = 60 + StatusCertificateNotAuthorised = 61 + StatusCertificateNotValid = 62 +) + +// Status code categories. +const ( + StatusClassInput = 1 + StatusClassSuccess = 2 + StatusClassRedirect = 3 + StatusClassTemporaryFailure = 4 + StatusClassPermanentFailure = 5 + StatusClassClientCertificateRequired = 6 +) + +// Response is a Gemini response. +type Response struct { + Status int + Meta string + Body []byte +} + +// Write writes the Response to the provided io.Writer. +func (r *Response) Write(w io.Writer) { + header := strconv.Itoa(r.Status) + " " + r.Meta + "\r\n" + w.Write([]byte(header)) + + // Only write response body on success + if r.Status/10 == StatusClassSuccess { + w.Write(r.Body) + } +} + +// Server is a Gemini server. +type Server struct { + Addr string + TLSConfig *tls.Config + Handler Handler +} + +// ListenAndServer listens on the given address and serves. +func (s *Server) ListenAndServe() error { + addr := s.Addr + if addr == "" { + addr = ":1965" + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + return err + } + defer ln.Close() + + tlsListener := tls.NewListener(ln, s.TLSConfig) + return s.Serve(tlsListener) +} + +// Serve listens for requests on the provided listener. +func (s *Server) Serve(ln net.Listener) error { + for { + rw, err := ln.Accept() + if err != nil { + return err + } + + req, err := readLine(rw) + if err != nil { + continue + } + url, err := url.Parse(req) + if err != nil { + continue + } + resp := s.Handler.Serve(url) + resp.Write(rw) + rw.Close() + } +} + +// Handler handles a url with a response. +type Handler interface { + // Serve accepts a url, as that is the only information that the client + // provides in a request. + Serve(*url.URL) *Response +} + +// Mux is a Gemini request multiplexer. +// It matches the URL of each incoming request against a list of registered +// patterns and calls the handler for the pattern that most closesly matches +// the URL. +type Mux struct { + entries []muxEntry +} + +type muxEntry struct { + scheme string + host string + path string + handler Handler +} + +func (m *Mux) match(url *url.URL) Handler { + for _, e := range m.entries { + if (e.scheme == "" || url.Scheme == e.scheme) && + (e.host == "" || url.Host == e.host) && + strings.HasPrefix(url.Path, e.path) { + return e.handler + } + } + return nil +} + +func (m *Mux) Handle(pattern string, handler Handler) { + url, err := url.Parse(pattern) + if err != nil { + panic(err) + } + m.entries = append(m.entries, muxEntry{ + url.Scheme, + url.Host, + url.Path, + handler, + }) +} + +func (m *Mux) HandleFunc(pattern string, handlerFunc func(url *url.URL) *Response) { + handler := HandlerFunc(handlerFunc) + m.Handle(pattern, handler) +} + +func (m *Mux) Serve(url *url.URL) *Response { + h := m.match(url) + if h == nil { + return &Response{ + Status: StatusNotFound, + Meta: "Not found", + } + } + return h.Serve(url) +} + +type HandlerFunc func(url *url.URL) *Response + +func (f HandlerFunc) Serve(url *url.URL) *Response { + return f(url) +} + +// readLine reads a line. +func readLine(r io.Reader) (string, error) { + scanner := bufio.NewScanner(r) + scanner.Scan() + if err := scanner.Err(); err != nil { + return "", err + } + return scanner.Text(), nil +}