From ba5b3dc5f09b4a461e4965944057bf41c9b1f517 Mon Sep 17 00:00:00 2001 From: Trevor Slocum Date: Thu, 29 Oct 2020 13:35:48 -0700 Subject: [PATCH] Initial commit --- .gitignore | 5 ++ .gitlab-ci.yml | 21 +++++ CONFIGURATION.md | 19 +++++ LICENSE | 21 +++++ README.md | 33 ++++++++ config.go | 57 +++++++++++++ go.mod | 9 +++ go.sum | 10 +++ main.go | 203 +++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 378 insertions(+) create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 CONFIGURATION.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb807f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +dist/ +vendor/ +*.sh +twins diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..221498a --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,21 @@ +image: golang:latest + +stages: + - validate + - build + +fmt: + stage: validate + script: + - gofmt -l -s -e . + - exit $(gofmt -l -s -e . | wc -l) + +vet: + stage: validate + script: + - go vet -composites=false ./... + +test: + stage: validate + script: + - go test -race -v ./... diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 0000000..69a889c --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,19 @@ +# config.yaml + +```yaml +# Path to certificate and private key +cert: /home/twins/data/certfile.crt +key: /home/twins/data/keyfile.key + +# Paths to serve +serve: + - + dir: /sites + root: /home/twins/data/sites + - + regexp: ^/(help|info)$ + root: /home/twins/data/help + - + dir: / + root: /home/twins/data/home +``` \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7c20f89 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Trevor Slocum + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a320445 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# twins +[![CI status](https://gitlab.com/tslocum/twins/badges/master/pipeline.svg)](https://gitlab.com/tslocum/twins/commits/master) +[![Donate](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space) + +[Gemini](https://gemini.circumlunar.space) server + +## Features + +- Serve static files + +## Download + +```bash +go get gitlab.com/tslocum/twins +``` + +## Configure + +See [CONFIGURATION.md](https://gitlab.com/tslocum/twins/blob/master/CONFIGURATION.md) + +## Run + +```bash +twins +``` + +## Support + +Please share issues and suggestions [here](https://gitlab.com/tslocum/twins/issues). + +## Dependencies + +- [go-gemini](https://github.com/makeworld-the-better-one/go-gemini) diff --git a/config.go b/config.go new file mode 100644 index 0000000..052063f --- /dev/null +++ b/config.go @@ -0,0 +1,57 @@ +package main + +import ( + "errors" + "io/ioutil" + "os" + "regexp" + + "gopkg.in/yaml.v3" +) + +type serveConfig struct { + Dir string + Regexp string + Root string + + r *regexp.Regexp +} + +type serverConfig struct { + Cert string + Key string + Serve []*serveConfig +} + +var config = &serverConfig{} + +func readconfig(configPath string) error { + if configPath == "" { + return errors.New("file unspecified") + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + return errors.New("file not found") + } + + configData, err := ioutil.ReadFile(configPath) + if err != nil { + return err + } + + err = yaml.Unmarshal(configData, &config) + if err != nil { + return err + } + + for _, serve := range config.Serve { + if serve.Dir != "" && serve.Dir[len(serve.Dir)-1] == '/' { + serve.Dir = serve.Dir[:len(serve.Dir)-1] + } + if serve.Regexp != "" { + serve.r = regexp.MustCompile(serve.Regexp) + } + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6bbebc4 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module gitlab.com/tslocum/twins + +go 1.15 + +require ( + github.com/h2non/filetype v1.1.0 + github.com/makeworld-the-better-one/go-gemini v0.9.0 + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..54f48bc --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/h2non/filetype v1.1.0 h1:Or/gjocJrJRNK/Cri/TDEKFjAR+cfG6eK65NGYB6gBA= +github.com/h2non/filetype v1.1.0/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= +github.com/makeworld-the-better-one/go-gemini v0.9.0 h1:Iz4ywRDrfsyoR8xZOkSKGXXftMR2spIV6ibVuhrKvSw= +github.com/makeworld-the-better-one/go-gemini v0.9.0/go.mod h1:P7/FbZ+IEIbA/d+A0Y3w2GNgD8SA2AcNv7aDGJbaWG4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5e3a3e3 --- /dev/null +++ b/main.go @@ -0,0 +1,203 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/tls" + "flag" + "fmt" + "io" + "log" + "net" + "net/url" + "os" + "path" + "strings" + + "github.com/h2non/filetype" + "github.com/makeworld-the-better-one/go-gemini" +) + +func writeHeader(c net.Conn, code int, meta string) { + fmt.Fprintf(c, "%d %s\r\n", code, meta) +} + +func respond(c net.Conn, code int) { + var meta string + switch code { + case gemini.StatusTemporaryFailure: + meta = "Temporary failure" + case gemini.StatusBadRequest: + meta = "Bad request" + case gemini.StatusNotFound: + meta = "Not found" + } + writeHeader(c, code, meta) +} + +func scanCRLF(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := bytes.IndexByte(data, '\r'); i >= 0 { + // We have a full newline-terminated line. + return i + 1, data[0:i], nil + } + // If we're at EOF, we have a final, non-terminated line. Return it. + if atEOF { + return len(data), data, nil + } + // Request more data. + return 0, nil, nil +} + +func handleConn(c net.Conn) { + defer c.Close() + + var requestData string + scanner := bufio.NewScanner(c) + scanner.Split(scanCRLF) + if scanner.Scan() { + requestData = scanner.Text() + } + if err := scanner.Err(); err != nil { + respond(c, gemini.StatusBadRequest) + return + } + + request, err := url.Parse(requestData) + if err != nil || request.Scheme != "gemini" || request.Host == "" { + respond(c, gemini.StatusBadRequest) + return + } + if request.Path == "" { + request.Path = "/" + } + pathBytes := []byte(request.Path) + strippedPath := request.Path + if strippedPath[0] == '/' { + strippedPath = strippedPath[1:] + } + + for _, serve := range config.Serve { + var realPath string + if serve.Dir != "" && strings.HasPrefix(request.Path, serve.Dir) { + realPath = path.Join(serve.Root, request.Path[len(serve.Dir):]) + } else if serve.r != nil && serve.r.Match(pathBytes) { + realPath = path.Join(serve.Root, strippedPath) + } else { + continue + } + + fi, err := os.Stat(realPath) + if os.IsNotExist(err) { + respond(c, gemini.StatusNotFound) + return + } else if err != nil { + respond(c, gemini.StatusTemporaryFailure) + return + } + + if mode := fi.Mode(); mode.IsDir() { + _, err := os.Stat(path.Join(realPath, "index.gemini")) + if err == nil { + realPath = path.Join(realPath, "index.gemini") + } else { + realPath = path.Join(realPath, "index.gmi") + } + } + + fi, err = os.Stat(realPath) + if os.IsNotExist(err) { + respond(c, gemini.StatusNotFound) + return + } else if err != nil { + respond(c, gemini.StatusTemporaryFailure) + return + } + + file, _ := os.Open(realPath) + defer file.Close() + + buf := make([]byte, 261) + n, _ := file.Read(buf) + + mimeType := "text/gemini; charset=utf-8" + if !strings.HasSuffix(realPath, ".gmi") && !strings.HasSuffix(realPath, ".gemini") { + if strings.HasSuffix(realPath, ".html") && strings.HasSuffix(realPath, ".htm") { + mimeType = "text/html; charset=utf-8" + } else if strings.HasSuffix(realPath, ".txt") && strings.HasSuffix(realPath, ".text") { + mimeType = "text/plain; charset=utf-8" + } else { + kind, _ := filetype.Match(buf[:n]) + if kind != filetype.Unknown { + mimeType = kind.MIME.Value + } + } + } + + writeHeader(c, gemini.StatusSuccess, mimeType) + c.Write(buf[:n]) + io.Copy(c, file) + return + } + + respond(c, gemini.StatusNotFound) +} + +func handleListener(l net.Listener) { + for { + conn, err := l.Accept() + if err != nil { + log.Fatal(err) + } + + go handleConn(conn) + } +} + +func main() { + configFile := flag.String("config", "", "path to configuration file") + certFile := flag.String("cert", "", "path to certificate file") + keyFile := flag.String("key", "", "path to private key file") + flag.Parse() + + if *configFile == "" { + homedir, err := os.UserHomeDir() + if err == nil && homedir != "" { + *configFile = path.Join(homedir, ".config", "twins", "config.yaml") + } + } + + err := readconfig(*configFile) + if err != nil && *certFile == "" { + log.Fatalf("failed to read configuration file at %s: %v\nSee CONFIGURATION.md for information on configuring twins", *configFile, err) + } + + if *certFile != "" { + config.Cert = *certFile + } + if *keyFile != "" { + config.Key = *keyFile + } + + if config.Cert == "" || config.Key == "" { + log.Fatal("certificate file and private key must be specified (gemini requires TLS for all connections)") + } + + cert, err := tls.LoadX509KeyPair(config.Cert, config.Key) + if err != nil { + log.Fatalf("failed to load certificate: %s", err) + } + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + + listener, err := tls.Listen("tcp", "localhost:8888", tlsConfig) + if err != nil { + log.Fatalf("failed to listen on %s: %s", "localhost:8888", err) + } + + handleListener(listener) +}