diff --git a/gemini/error.go b/gemini/error.go new file mode 100644 index 0000000..ac4b0a9 --- /dev/null +++ b/gemini/error.go @@ -0,0 +1,18 @@ +package gemini + +type GmiError struct { + Code int + err error +} + +func Error(code int, err error) error { + return &GmiError{Code: code, err: err} +} + +func (e *GmiError) Error() string { + return e.err.Error() +} + +func (e *GmiError) Unwrap() error { + return e.err +} diff --git a/gemini/gemini.go b/gemini/gemini.go new file mode 100644 index 0000000..afbded6 --- /dev/null +++ b/gemini/gemini.go @@ -0,0 +1,223 @@ +package gemini + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "log" + "net" + "net/url" + "path" + "strings" + "time" + "unicode/utf8" +) + +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 + StatusCertificateNotAuthorized = 61 + StatusCertificateNotValid = 62 +) + +const ( + Termination = "\r\n" + URLMaxBytes = 1024 + IndexFile = "index.gmi" + MimeType = "text/gemini; charset=utf-8" +) + +var ( + ErrServerClosed = errors.New("gemini: server closed") + ErrHeaderTooLong = errors.New("gemini: header too long") + ErrMissingFile = errors.New("gemini: no such file") +) + +type Request struct { + ctx context.Context + URL *url.URL +} + +type Handler interface { + ServeGemini(io.Writer, *Request) +} + +// The HandlerFunc type is an adapter to allow the use of +// ordinary functions as Gemini handlers. If f is a function +// with the appropriate signature, HandlerFunc(f) is a +// Handler that calls f. +type HandlerFunc func(io.Writer, *Request) + +// ServeGemini calls f(w, r). +func (f HandlerFunc) ServeGemini(w io.Writer, r *Request) { + f(w, r) +} + +type Server struct { + // Addr is the address the server is listening on. + Addr string + + // Hostname or common name of the server. This is used for absolute redirects. + Hostname string + + TLSConfig *tls.Config + Handler Handler // handler to invoke + ReadTimeout time.Duration + MaxOpenConns int +} + +func (s *Server) ListenAndServe() error { + // outer for loop, if listener closes we will restart it. This may be useful if we switch out + // TLSConfig. + //for { + listener, err := tls.Listen("tcp", s.Addr, s.TLSConfig) + if err != nil { + return fmt.Errorf("gemini server listen: %w", err) + } + + queue := make(chan net.Conn, s.MaxOpenConns) + go s.handleConnectionQueue(queue) + + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("server: accept: %s", err) + break + } + queue <- conn + } + //} + return nil +} + +func (s *Server) handleConnectionQueue(queue chan net.Conn) { + // semaphore for connection limiter + type semaphore chan struct{} + sem := make(semaphore, s.MaxOpenConns) + for { + // for each connection we receive + conn := <-queue + sem <- struct{}{} // acquire + go s.handleConnection(conn, sem) + } +} + +func (s *Server) handleConnection(conn net.Conn, sem chan struct{}) { + defer func() { + conn.Close() + <-sem // release + }() + reqChan := make(chan request) + // push job for which we allocated a sem slot and wait + go requestChannel(conn, reqChan) + select { + case header := <-reqChan: + fmt.Println("serve") + if header.err != nil { + s.handleRequestError(conn, header) + return + } + ctx := context.Background() + r := &Request{ctx: ctx, URL: header.URL} + s.Handler.ServeGemini(conn, r) + case <-time.After(s.ReadTimeout): + WriteHeader(conn, StatusServerUnavailable, "") + } +} + +func (s *Server) handleRequestError(conn net.Conn, req request) { + // TODO: log err + var gmierr *GmiError + if errors.As(req.err, &gmierr) { + WriteHeader(conn, gmierr.Code, gmierr.Error()) + return + } + + // this path doesn't exist currently. + WriteHeader(conn, StatusTemporaryFailure, "internal") +} + +// conn handler + +type request struct { + URL *url.URL + err error +} + +func requestChannel(c net.Conn, rsp chan request) { + u, err := readHeader(c) + rsp <- request{u, err} +} + +func readHeader(c net.Conn) (*url.URL, error) { + req, err := bufio.NewReader(c).ReadString('\r') + if err != nil { + return nil, Error(StatusTemporaryFailure, errors.New("error reading request")) + } + + requestURL := strings.TrimSpace(req) + if requestURL == "" { + return nil, Error(StatusBadRequest, errors.New("empty request URL")) + } else if !utf8.ValidString(requestURL) { + return nil, Error(StatusBadRequest, errors.New("not a valid utf-8 url")) + } else if len(requestURL) > URLMaxBytes { + return nil, Error(StatusBadRequest, ErrHeaderTooLong) + } + + parsedURL, err := url.Parse(requestURL) + if err != nil { + return nil, Error(StatusBadRequest, err) + } + + if parsedURL.Scheme != "" && parsedURL.Scheme != "gemini" { + return nil, Error(StatusProxyRequestRefused, fmt.Errorf("unknown protocol scheme %s", parsedURL.Scheme)) + } else if parsedURL.Host == "" { + return nil, Error(StatusBadRequest, errors.New("empty host")) + } + + if parsedURL.Path == "" { + return nil, Error(StatusRedirectPermanent, errors.New("./"+parsedURL.Path)) + } else if parsedURL.Path != path.Clean(parsedURL.Path) { + return nil, Error(StatusBadRequest, errors.New("path error")) + } + + return parsedURL, nil +} + +func (s *Server) Shutdown(ctx context.Context) error { + + return nil +} + +func WriteHeader(c io.Writer, code int, message string) { + // + var header []byte + if len(message) == 0 { + header = []byte(fmt.Sprintf("%d%s", code, Termination)) + } + header = []byte(fmt.Sprintf("%d %s%s", code, message, Termination)) + c.Write(header) +} + +func Write(c io.Writer, body []byte) { + reader := bytes.NewReader(body) + io.Copy(c, reader) +} diff --git a/gemini/tlsconfig.go b/gemini/tlsconfig.go new file mode 100644 index 0000000..540be68 --- /dev/null +++ b/gemini/tlsconfig.go @@ -0,0 +1,22 @@ +package gemini + +import ( + "crypto/rand" + "crypto/tls" +) + +func TLSConfig(sni string, cert tls.Certificate) *tls.Config { + return &tls.Config{ + ServerName: sni, + Certificates: []tls.Certificate{cert}, + Rand: rand.Reader, + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, + PreferServerCipherSuites: true, + CipherSuites: []uint16{ + tls.TLS_AES_128_GCM_SHA256, + tls.TLS_CHACHA20_POLY1305_SHA256, + tls.TLS_AES_256_GCM_SHA384, + }, + } +} diff --git a/main.go b/main.go index d29a2ac..774c700 100644 --- a/main.go +++ b/main.go @@ -1,73 +1,44 @@ package main import ( - "bufio" - "crypto/rand" "crypto/tls" "errors" "flag" "fmt" "io" + "io/ioutil" "log" "mime" - "net" - "net/url" "os" "path" - "strings" "time" - "unicode/utf8" "github.com/n0x1m/gmifs/gemini" ) const ( - // Input = 10 - // SensitiveInput = 11 - Success = 20 - RedirectTemporary = 30 - RedirectPermanent = 31 - TemporaryFailure = 40 - ServerUnavailable = 41 - // CgiError = 42 - // ProxyError = 43 - // SlowDown = 44 - // PermanentFailure = 50 - NotFound = 51 - // Gone = 52 - ProxyRequestRefused = 53 - BadRequest = 59 - // ClientCertificateRequired = 60 - // CertificateNotAuthorized = 61 - // CertificateNotValid = 62 -) + defaultAddress = ":1965" + defaultMaxConns = 256 + defaultTimeout = 10 + defaultRootPath = "/var/www/htdocs/gemini" + defaultHost = "" + defaultCertPath = "" + defaultKeyPath = "" -const ( - Termination = "\r\n" - URLMaxBytes = 1024 - IndexGmi = "index.gmi" - GeminiMIME = "text/gemini" - - DefaultAddress = ":1965" - DefaultMaxConns = 256 - DefaultTimeout = 10 - DefaultRootPath = "/var/www/htdocs/gemini" - DefaultCN = "" - DefaultCertPath = "" - DefaultKeyPath = "" + shutdownTimeout = 10 * time.Second ) func main() { - var address, root, crt, key, cn string + var addr, root, crt, key, host string var maxconns, timeout int - flag.StringVar(&address, "address", DefaultAddress, "address to listen on. E.g. 127.0.0.1:1965") - flag.IntVar(&maxconns, "max-conns", DefaultMaxConns, "maximum number of concurrently open connections") - flag.IntVar(&timeout, "timeout", DefaultTimeout, "connection timeout in seconds") - flag.StringVar(&root, "root", DefaultRootPath, "server root directory to serve from") - flag.StringVar(&cn, "cn", DefaultCN, "x509 Common Name when using temporary self-signed certs") - flag.StringVar(&crt, "cert", DefaultCertPath, "TLS chain of one or more certificates") - flag.StringVar(&key, "key", DefaultKeyPath, "TLS private key") + flag.StringVar(&addr, "addr", defaultAddress, "address to listen on. E.g. 127.0.0.1:1965") + flag.IntVar(&maxconns, "max-conns", defaultMaxConns, "maximum number of concurrently open connections") + flag.IntVar(&timeout, "timeout", defaultTimeout, "connection timeout in seconds") + flag.StringVar(&root, "root", defaultRootPath, "server root directory to serve from") + flag.StringVar(&host, "host", defaultHost, "hostname / x509 Common Name when using temporary self-signed certs") + flag.StringVar(&crt, "cert", defaultCertPath, "TLS chain of one or more certificates") + flag.StringVar(&key, "key", defaultKeyPath, "TLS private key") flag.Parse() var err error @@ -78,19 +49,52 @@ func main() { if err != nil { log.Fatalf("server: loadkeys: %s", err) } - } else if cn != "" { + } else if host != "" { log.Println("generating self-signed temporary certificate") - cert, err = gemini.GenX509KeyPair(cn) + cert, err = gemini.GenX509KeyPair(host) if err != nil { log.Fatalf("server: loadkeys: %s", err) } - } else { - fmt.Fprintf(os.Stderr, "need either a keypair with cert and key or a common name (hostname)\n") + } + if host == "" { + fmt.Fprintf(os.Stderr, "a keypair with cert and key or at least a common name (hostname) is required for sni\n") fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) flag.PrintDefaults() os.Exit(1) } + server := &gemini.Server{ + Addr: addr, + Hostname: host, + TLSConfig: gemini.TLSConfig(host, cert), + Handler: gemini.HandlerFunc(fileserver(root)), + MaxOpenConns: maxconns, + ReadTimeout: time.Duration(timeout) * time.Second, + } + + //confirm := make(chan struct{}, 1) + //go func() { + if err := server.ListenAndServe(); err != nil && !errors.Is(err, gemini.ErrServerClosed) { + log.Fatal("ListenAndServe terminated unexpectedly") + } + + // close(confirm) + //}() + + /* + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt) + <-stop + + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + if err := server.Shutdown(ctx); err != nil { + cancel() + log.Fatal("ListenAndServe shutdown") + } + + <-confirm + cancel() + */ /* hup := make(chan os.Signal, 1) signal.Notify(hup, syscall.SIGHUP) @@ -100,217 +104,71 @@ func main() { } }() */ +} - config := &tls.Config{ - Certificates: []tls.Certificate{cert}, - Rand: rand.Reader, - MinVersion: tls.VersionTLS12, - CurvePreferences: []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256}, - PreferServerCipherSuites: true, - CipherSuites: []uint16{ - tls.TLS_AES_128_GCM_SHA256, - tls.TLS_CHACHA20_POLY1305_SHA256, - tls.TLS_AES_256_GCM_SHA384, - }, - } - listener, err := tls.Listen("tcp", address, config) - if err != nil { - log.Fatalf("server: listen: %s", err) - } - - queue := make(chan net.Conn, maxconns) - go func() { - type semaphore chan struct{} - sem := make(semaphore, maxconns) - for { - // for each connection we receive - conn := <-queue - sem <- struct{}{} // acquire - go func() { - defer func() { - conn.Close() - <-sem // release - }() - handler := make(chan Response) - go handleConnectionChannel(conn, root, handler) - select { - case rsp := <-handler: - response, err := rsp.res, rsp.err - - var gmierr *GmiError - if err != nil && errors.As(err, &gmierr) { - if gmierr.Code == RedirectPermanent || gmierr.Code == RedirectTemporary { - // error is relative path if redirect - redirect := "gemini://" + cn + err.Error() - sendError(conn, gmierr.Code, redirect) - - return - } - sendError(conn, gmierr.Code, err.Error()) - - return - } - - sendFile(conn, response.file, response.mimeType) - response.file.Close() - case <-time.After(10 * time.Second): - sendError(conn, ServerUnavailable, "Server Unavailable") - } - }() - } - }() - - for { - conn, err := listener.Accept() +func fileserver(root string) func(w io.Writer, r *gemini.Request) { + return func(w io.Writer, r *gemini.Request) { + fullpath, err := fullPath(root, r.URL.Path) if err != nil { - log.Printf("server: accept: %s", err) - break + gemini.WriteHeader(w, gemini.StatusNotFound, err.Error()) + return } - queue <- conn + body, mimeType, err := readFile(fullpath) + if err != nil { + gemini.WriteHeader(w, gemini.StatusNotFound, err.Error()) + return + } + + gemini.WriteHeader(w, gemini.StatusSuccess, mimeType) + gemini.Write(w, body) } } -type Response struct { - res File - err error -} +func fullPath(root, requestPath string) (string, error) { + if requestPath == "/" || requestPath == "." { + return path.Join(root, gemini.IndexFile), nil + } -type File struct { - file *os.File - mimeType string -} + fullpath := path.Join(root, requestPath) -type GmiError struct { - Code int - err error -} - -func Error(code int, err error) error { - return &GmiError{Code: code, err: err} -} - -func (e *GmiError) Error() string { - return e.err.Error() -} - -func (e *GmiError) Unwrap() error { - return e.err -} - -func handleConnectionChannel(c net.Conn, root string, rsp chan Response) { - result, err := handleConnection(c, root) + pathInfo, err := os.Stat(fullpath) if err != nil { - rsp <- Response{err: err} - return - } - rsp <- Response{res: *result, err: err} -} - -func handleConnection(c net.Conn, root string) (*File, error) { - req, err := bufio.NewReader(c).ReadString('\r') - if err != nil { - return nil, Error(TemporaryFailure, errors.New("error reading request")) - } - fmt.Printf("%s\n", req) - - requestURL := strings.TrimSpace(req) - if requestURL == "" { - return nil, Error(BadRequest, errors.New("empty request URL")) - } else if !utf8.ValidString(requestURL) { - return nil, Error(BadRequest, errors.New("not a valid utf-8 url")) - } else if len(requestURL) > URLMaxBytes { - return nil, Error(BadRequest, errors.New("url exceeds maximum allowed length")) - } - - parsedURL, err := url.Parse(requestURL) - if err != nil { - return nil, Error(BadRequest, err) - } - - if parsedURL.Scheme == "" { - parsedURL.Scheme = "gemini" - } - if parsedURL.Scheme != "gemini" { - return nil, Error(ProxyRequestRefused, fmt.Errorf("unknown protocol scheme %s", parsedURL.Scheme)) - } else if parsedURL.Host == "" { - return nil, Error(BadRequest, errors.New("empty host")) - } - - _, port, _ := net.SplitHostPort(c.LocalAddr().String()) - if parsedURL.Port() != "" && parsedURL.Port() != port { - return nil, Error(ProxyRequestRefused, errors.New("faulty port")) - } - - return handleRequest(c, root, parsedURL) -} - -func handleRequest(c net.Conn, root string, parsedURL *url.URL) (*File, error) { - if parsedURL.Path == "" { - return nil, Error(RedirectPermanent, errors.New(parsedURL.Path)) - } else if parsedURL.Path != path.Clean(parsedURL.Path) { - return nil, Error(BadRequest, errors.New("path error")) - } - - if parsedURL.Path == "/" || parsedURL.Path == "." { - return serveFile(root, IndexGmi) - } - return serveFile(root, parsedURL.Path) -} - -func serveFile(root, filepath string) (*File, error) { - fullPath := path.Join(root, filepath) - - pathInfo, err := os.Stat(fullPath) - if err != nil { - return nil, Error(NotFound, err) + return "", fmt.Errorf("path: %w", err) } if pathInfo.IsDir() { - subDirIndex := path.Join(fullPath, IndexGmi) + subDirIndex := path.Join(fullpath, gemini.IndexFile) if _, err := os.Stat(subDirIndex); os.IsNotExist(err) { - return nil, Error(NotFound, err) + return "", fmt.Errorf("path: %w", err) } - fullPath = subDirIndex + fullpath = subDirIndex } - mimeType := getMimeType(fullPath) + return fullpath, nil +} + +func readFile(filepath string) ([]byte, string, error) { + mimeType := getMimeType(filepath) if mimeType == "" { - return nil, Error(NotFound, errors.New("unsupported")) + return nil, "", errors.New("unsupported") } - file, err := os.Open(fullPath) + file, err := os.Open(filepath) if err != nil { - return nil, Error(NotFound, err) + return nil, "", fmt.Errorf("file: %w", err) } - - return &File{file: file, mimeType: mimeType}, nil -} - -func sendError(c net.Conn, code int, message string) { - c.Write(header(code, message)) -} - -func sendFile(c net.Conn, file *os.File, mimeType string) { - if file != nil { - c.Write(header(Success, mimeType)) - io.Copy(c, file) - return + defer file.Close() + data, err := ioutil.ReadAll(file) + if err != nil { + return nil, "", fmt.Errorf("read: %w", err) } - sendError(c, TemporaryFailure, "file handler failed") + return data, mimeType, nil } -func header(code int, message string) []byte { - // - if len(message) == 0 { - return []byte(fmt.Sprintf("%d%s", code, Termination)) - } - return []byte(fmt.Sprintf("%d %s%s", code, message, Termination)) -} - -func getMimeType(fullPath string) string { - if ext := path.Ext(fullPath); ext != ".gmi" { +func getMimeType(fullpath string) string { + if ext := path.Ext(fullpath); ext != ".gmi" { return mime.TypeByExtension(ext) } - return GeminiMIME + return gemini.MimeType }