aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md100
-rw-r--r--client.go135
-rw-r--r--go.mod3
-rw-r--r--server.go185
4 files changed, 423 insertions, 0 deletions
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
+}