diff --git a/README.md b/README.md index 60accf3..668fe60 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,54 @@ # gmifs -Gemini File Server, short gmifs, is intended to be minimal and serve static files only. It is used +Gemini File Server, short gmifs, is intended to be minimal and serve static files. It is used to accompany a hugo blog served via httpd and make it available via the [gemini protocol](https://gemini.circumlunar.space/docs/specification.gmi). Why built yet another gemini server? Because it's educational and that's the spirit of the protocol. Features -- zero conf -- zero dependencies (pure go, standard library only) -- only modern tls ciphers (from Mozilla's [TLS ciphers recommendation](https://statics.tls.security.mozilla.org/server-side-tls-conf.json)) -- concurrent request limiter +- **zero conf**, if no certificate is available, gmifs can generates self-signed certs +- **zero dependencies**, Go standard library only +- directory listing support +- only modern tls ciphers (from [Mozilla's TLS ciphers recommendations](https://statics.tls.security.mozilla.org/server-side-tls-conf.json)) +- concurrent requests limiter +- reloads ssl certs and flushes/reopens log files on SIGHUP +- single file gemini implementation, focus on simplicity, no bells and whistles This tool is used alongside the markdown to gemtext converter [md2gmi](https://github.com/n0x1m/md2gmi). -Generate a self-signed server certificate with openssl: +## Usage + +### Dev & Tests + +Test it locally by serving e.g. a `./public` directory on localhost with directory listing turned on + +``` +./gmifs -root ./public -host localhost -autoindex +``` + +### Production + +In the real world generate a self-signed server certificate with OpenSSL or use a Let's Encrypt +key pair ```bash openssl req -x509 -newkey rsa:4096 -keyout key.rsa -out cert.pem \ -days 3650 -nodes -subj "/CN=nox.im" ``` -## Usage - -locally test it by serving a `./gemini` directory - -``` -./gmifs -root ./gemini -``` - -full example +start gmifs with the keypair ``` gmifs -addr 0.0.0.0:1965 -root /var/www/htdocs/nox.im/gemini \ + -host nox.im -max-conns 1024 -timeout 5 \ + -logs /var/gemini/logs/ \ -cert /etc/ssl/nox.im.fullchain.pem \ -key /etc/ssl/private/nox.im.key ``` + +if need be, send SIGHUP to reload the certificate without downtime + +``` +pgrep gmifs | awk '{print "kill -1 " $1}' | sh +``` diff --git a/main.go b/main.go index a986559..fec8563 100644 --- a/main.go +++ b/main.go @@ -6,16 +6,14 @@ import ( "errors" "flag" "fmt" - "io" - "io/ioutil" "log" - "mime" "os" "os/signal" - "path" "path/filepath" + "syscall" "time" + "github.com/n0x1m/gmifs/fileserver" "github.com/n0x1m/gmifs/gemini" "github.com/n0x1m/gmifs/middleware" ) @@ -32,8 +30,6 @@ const ( shutdownTimeout = 10 * time.Second ) -var ErrDirWithoutIndex = errors.New("path is directory without index.gmi") - func main() { var addr, root, crt, key, host, logs string var maxconns, timeout int @@ -51,36 +47,20 @@ func main() { flag.BoolVar(&autoindex, "autoindex", false, "enables or disables the directory listing output") flag.Parse() - // TODO: rotate on SIGHUP - mlogger := log.New(os.Stdout, "", log.LUTC|log.Ldate|log.Ltime) - if logs != "" { - logpath := filepath.Join(logs, "access.log") - accessLog, err := os.OpenFile(logpath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + var err error + var flogger, dlogger *log.Logger + flogger, err = setupLogger(logs, "access.log") + if err != nil { + log.Fatal(err) + } + + if debug { + dlogger, err = setupLogger(logs, "debug.log") if err != nil { log.Fatal(err) } - defer accessLog.Close() - - mlogger.SetOutput(accessLog) } - var dlogger *log.Logger - if debug { - dlogger = log.New(os.Stdout, "", log.LUTC|log.Ldate|log.Ltime) - - if logs != "" { - logpath := filepath.Join(logs, "debug.log") - debugLog, err := os.OpenFile(logpath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) - if err != nil { - log.Fatal(err) - } - defer debugLog.Close() - - dlogger.SetOutput(debugLog) - } - } - - var err error var cert tls.Certificate if crt != "" && key != "" { log.Println("loading certificate from", crt) @@ -103,8 +83,8 @@ func main() { } mux := gemini.NewMux() - mux.Use(middleware.Logger(mlogger)) - mux.Handle(gemini.HandlerFunc(fileserver(root, true))) + mux.Use(middleware.Logger(flogger)) + mux.Handle(gemini.HandlerFunc(fileserver.Serve(root, true))) server := &gemini.Server{ Addr: addr, @@ -131,109 +111,47 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) if err := server.Shutdown(ctx); err != nil { cancel() - log.Fatal("ListenAndServe shutdown") + log.Fatalf("ListenAndServe shutdown with error: %v", err) } <-confirm cancel() - } -func fileserver(root string, dirlisting bool) func(w io.Writer, r *gemini.Request) { - return func(w io.Writer, r *gemini.Request) { - fullpath, err := fullPath(root, r.URL.Path) +func setupLogger(dir, filename string) (*log.Logger, error) { + logger := log.New(os.Stdout, "", log.LUTC|log.Ldate|log.Ltime) + + if dir != "" { + // non 12factor stuff + logpath := filepath.Join(dir, filename) + _, err := setupFileLogging(logger, logpath) if err != nil { - if err == ErrDirWithoutIndex && dirlisting { - body, mimeType, err := listDirectory(fullpath, r.URL.Path) + log.Fatalf("failed to open log file: %v", err) + } + + go func(logger *log.Logger, logpath string) { + hup := make(chan os.Signal, 1) + signal.Notify(hup, syscall.SIGHUP) + for { + <-hup + logger.Println("rotating log file after SIGHUP") + _, err := setupFileLogging(logger, logpath) if err != nil { - gemini.WriteHeader(w, gemini.StatusNotFound, err.Error()) - return + log.Fatalf("failed to rotate log file: %v", err) } - - gemini.WriteHeader(w, gemini.StatusSuccess, mimeType) - gemini.Write(w, body) - return } - gemini.WriteHeader(w, gemini.StatusNotFound, err.Error()) - return - } - 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) + }(logger, logpath) } + return logger, nil } -func fullPath(root, requestPath string) (string, error) { - fullpath := path.Join(root, requestPath) - - pathInfo, err := os.Stat(fullpath) +func setupFileLogging(logger *log.Logger, logpath string) (*os.File, error) { + logfile, err := os.OpenFile(logpath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { - return "", fmt.Errorf("path: %w", err) + return logfile, err } - if pathInfo.IsDir() { - subDirIndex := path.Join(fullpath, gemini.IndexFile) - if _, err := os.Stat(subDirIndex); os.IsNotExist(err) { - return fullpath, ErrDirWithoutIndex - } - - fullpath = subDirIndex - } - - return fullpath, nil -} - -func readFile(filepath string) ([]byte, string, error) { - mimeType := getMimeType(filepath) - if mimeType == "" { - return nil, "", errors.New("disabled/unsupported file type") - } - - file, err := os.Open(filepath) - if err != nil { - return nil, "", fmt.Errorf("file: %w", err) - } - defer file.Close() - data, err := ioutil.ReadAll(file) - if err != nil { - return nil, "", fmt.Errorf("read: %w", err) - } - return data, mimeType, nil -} - -func getMimeType(fullpath string) string { - if ext := path.Ext(fullpath); ext != ".gmi" { - return mime.TypeByExtension(ext) - } - return gemini.MimeType -} - -func listDirectory(fullpath, relpath string) ([]byte, string, error) { - files, err := ioutil.ReadDir(fullpath) - if err != nil { - return nil, "", err - } - - var out []byte - parent := filepath.Dir(relpath) - if relpath != "/" { - out = append(out, []byte(fmt.Sprintf("Index of %s/\n\n", relpath))...) - out = append(out, []byte(fmt.Sprintf("=> %s ..\n", parent))...) - } else { - out = append(out, []byte(fmt.Sprintf("Index of %s\n\n", relpath))...) - } - for _, f := range files { - if relpath == "/" { - out = append(out, []byte(fmt.Sprintf("=> %s\n", f.Name()))...) - } else { - out = append(out, []byte(fmt.Sprintf("=> %s/%s %s\n", relpath, f.Name(), f.Name()))...) - } - } - - return out, gemini.MimeType, nil + logger.SetOutput(logfile) + return logfile, nil }