mirror of
https://code.rocketnine.space/tslocum/twins.git
synced 2024-11-27 09:38:14 +01:00
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