Initial commit
This commit is contained in:
commit
ba5b3dc5f0
9 changed files with 378 additions and 0 deletions
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
.idea/
|
||||
dist/
|
||||
vendor/
|
||||
*.sh
|
||||
twins
|
21
.gitlab-ci.yml
Normal file
21
.gitlab-ci.yml
Normal file
|
@ -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 ./...
|
19
CONFIGURATION.md
Normal file
19
CONFIGURATION.md
Normal file
|
@ -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
|
||||
```
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2020 Trevor Slocum <trevor@rocketnine.space>
|
||||
|
||||
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.
|
33
README.md
Normal file
33
README.md
Normal file
|
@ -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)
|
57
config.go
Normal file
57
config.go
Normal file
|
@ -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
|
||||
}
|
9
go.mod
Normal file
9
go.mod
Normal file
|
@ -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
|
||||
)
|
10
go.sum
Normal file
10
go.sum
Normal file
|
@ -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=
|
203
main.go
Normal file
203
main.go
Normal file
|
@ -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)
|
||||
}
|
Loading…
Reference in a new issue