First working prototype

This commit contains the first working prototype.
This commit is contained in:
Felix Niederwanger 2022-02-05 10:33:44 +01:00
parent 4090c8d4d1
commit 39940039b9
Signed by: phoenix
GPG key ID: 6E77A590E3F6D71C
7 changed files with 297 additions and 19 deletions

6
.gitignore vendored
View file

@ -5,6 +5,7 @@
*.dll
*.so
*.dylib
/orion
# Test binary, built with `go test -c`
*.test
@ -13,5 +14,8 @@
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
vendor/
# Certificates
*.crt
*.key

10
Makefile Normal file
View file

@ -0,0 +1,10 @@
default: all
all: orion
orion: cmd/orion/orion.go cmd/orion/gemini.go cmd/orion/config.go
go build -o $@ $^
cert:
openssl genrsa -out orion.key 2048
openssl req -x509 -nodes -days 3650 -key orion.key -out orion.crt

62
cmd/orion/config.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
type Config struct {
Hostname string // Server hostname
CertFile string // Certificate filename
Keyfile string // Key file
BindAddr string // Optional binding address
ContentDir string // Gemini content directory to serve
}
func (cf *Config) SetDefaults() {
cf.Hostname = "localhost"
cf.CertFile = "orion.crt"
cf.Keyfile = "orion.key"
cf.BindAddr = ":1967"
cf.ContentDir = "gemini/"
}
func (cf *Config) LoadConfigFile(filename string) error {
file, err := os.Open(filename)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
lineCount := 0
for scanner.Scan() {
lineCount++
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' {
continue
}
i := strings.Index(line, "=")
if i < 0 {
return fmt.Errorf("Syntax error in line %d", lineCount)
}
name, value := strings.ToLower(strings.TrimSpace(line[:i])), strings.TrimSpace(line[i+1:])
if name == "hostname" {
cf.Hostname = value
} else if name == "certfile" {
cf.CertFile = value
} else if name == "keyfile" {
cf.Keyfile = value
} else if name == "bind" {
cf.BindAddr = value
} else if name == "contentdir" {
cf.ContentDir = value
} else {
return fmt.Errorf("Unknown setting in line %d", lineCount)
}
}
return file.Close()
}

View file

@ -4,10 +4,12 @@ import (
"crypto/tls"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"unicode"
)
const (
@ -20,18 +22,20 @@ const (
type GeminiHandler func(path string, conn io.ReadWriteCloser) error
func ServerGemini(hostname string, bindAddr string, cert tls.Certificate, handler GeminiHandler) error {
// TLS session
cfg := &tls.Config{Certificates: []tls.Certificate{cert}, ServerName: hostname, MinVersion: tls.VersionTLS12}
listener, err := tls.Listen("tcp", bindAddr, cfg)
if err != nil {
return err
}
type GeminiServer struct {
server net.Listener
}
func (srv *GeminiServer) Close() error {
return srv.server.Close()
}
func (srv *GeminiServer) Loop(handler GeminiHandler) error {
for {
conn, err := listener.Accept()
conn, err := srv.server.Accept()
if err != nil {
if err == io.EOF {
break
return nil
} else {
fmt.Fprintf(os.Stderr, "accept error: %s\n", err)
continue
@ -39,34 +43,68 @@ func ServerGemini(hostname string, bindAddr string, cert tls.Certificate, handle
}
go handleConnection(conn, handler)
}
return nil
}
func sendResponse(conn io.ReadWriteCloser, statusCode int, meta string) error {
func CreateGeminiServer(hostname string, bindAddr string, cert tls.Certificate) (GeminiServer, error) {
var srv GeminiServer
var err error
// TLS session
cfg := &tls.Config{Certificates: []tls.Certificate{cert}, ServerName: hostname, MinVersion: tls.VersionTLS12}
srv.server, err = tls.Listen("tcp", bindAddr, cfg)
return srv, err
}
func SendResponse(conn io.WriteCloser, statusCode int, meta string) error {
header := fmt.Sprintf("%d %s\r\n", statusCode, meta)
_, err := conn.Write([]byte(header))
return err
}
func SendContent(conn io.WriteCloser, content []byte, meta string) error {
if err := SendResponse(conn, StatusSuccess, meta); err != nil {
return err
}
_, err := conn.Write(content)
return err
}
// sanitize an input path, ignores all characters that are not alphanumeric or a path separator /
func sanitizePath(path string) string {
var ret string
chars := []rune(path)
for _, c := range chars {
if unicode.IsLetter(c) || unicode.IsDigit(c) || c == '/' || c == '.' {
ret += string(c)
}
}
return ret
}
func handleConnection(conn io.ReadWriteCloser, handler GeminiHandler) error {
defer conn.Close()
// 1500 matches the typical MTU size
buf := make([]byte, 1500)
n, err := conn.Read(buf)
if err != nil {
return err
}
if n > 1024 {
return sendResponse(conn, statusPermanentFailure, "Request exceeds maximum permitted length")
return SendResponse(conn, StatusPermanentFailure, "Request exceeds maximum permitted length")
}
// Parse incoming request URL.
reqURL, err := url.Parse(string(buf))
surl := strings.TrimSpace(string(buf[:n]))
reqURL, err := url.Parse(surl)
if err != nil {
return sendResponse(conn, statusPermanentFailure, "URL incorrectly formatted")
return SendResponse(conn, StatusPermanentFailure, "URL incorrectly formatted")
}
// If the URL ends with a '/' character, assume that the user wants the index.gmi
// file in the corresponding directory.
// Do not allow traverse paths
if strings.Contains(reqURL.Path, "..") {
return SendResponse(conn, StatusPermanentFailure, "Path traverse not supported by this server")
}
// If the URL ends with a '/', serve the index.gmi
var reqPath string
if strings.HasSuffix(reqURL.Path, "/") || reqURL.Path == "" {
reqPath = filepath.Join(reqURL.Path, "index.gmi")
@ -74,6 +112,7 @@ func handleConnection(conn io.ReadWriteCloser, handler GeminiHandler) error {
reqPath = reqURL.Path
}
cleanPath := filepath.Clean(reqPath)
cleanPath := sanitizePath(filepath.Clean(reqPath))
return handler(cleanPath, conn)
}

View file

@ -1,5 +1,150 @@
package main
func main() {
import (
"crypto/tls"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
)
var config Config
func DirectoryExists(dirpath string) bool {
stat, err := os.Stat(dirpath)
if err != nil {
return false
}
return stat.IsDir()
}
func FileExists(dirpath string) bool {
// Quick and dirty check
_, err := os.Stat(dirpath)
return err == nil
}
// try to load the given config file. Ignores the file if not present. Exits the program on failure
func tryLoadConfig(filename string) {
if FileExists(filename) {
if err := config.LoadConfigFile(filename); err != nil {
fmt.Fprintf(os.Stderr, "Error loading %s: %s\n", filename, err)
os.Exit(1)
}
}
}
func main() {
config.SetDefaults()
customConfigFile := flag.String("config", "", "Configuration file")
flag.Parse()
if *customConfigFile != "" {
if err := config.LoadConfigFile(*customConfigFile); err != nil {
fmt.Fprintf(os.Stderr, "Error loading %s: %s\n", *customConfigFile, err)
os.Exit(1)
}
} else {
// Load default configuration files, if existing
tryLoadConfig("/etc/orion.conf")
tryLoadConfig("./orion.conf")
}
// Make the content dir absolute
if !strings.HasPrefix(config.ContentDir, "/") {
workDir, err := os.Getwd()
if err != nil {
fmt.Fprintf(os.Stderr, "error getting the work directory: %s\n", err)
os.Exit(1)
}
config.ContentDir = workDir + "/" + config.ContentDir
}
// Terminate content directory with a '/'
if !strings.HasSuffix(config.ContentDir, "/") {
config.ContentDir += "/"
}
// Check settings
if !FileExists(config.Keyfile) {
fmt.Fprintf(os.Stderr, "Server key file not found: %s\n", config.Keyfile)
os.Exit(1)
}
if !FileExists(config.CertFile) {
fmt.Fprintf(os.Stderr, "Certificate file not found: %s\n", config.CertFile)
os.Exit(1)
}
// Content warnings should point user at wrong configuration early in the program
if !DirectoryExists(config.ContentDir) {
fmt.Fprintf(os.Stderr, "WARNING: Content directory does not exist: %s\n", config.ContentDir)
} else {
if !FileExists(config.ContentDir + "/index.gmi") {
fmt.Fprintf(os.Stderr, "WARNING: index.gmi does not exists in content directory: %s/index.gmi\n", config.ContentDir)
}
}
// Setup gemini server
cert, err := tls.LoadX509KeyPair(config.CertFile, config.Keyfile)
if err != nil {
fmt.Fprintf(os.Stderr, "certificate error: %s\n", err)
os.Exit(1)
}
server, err := CreateGeminiServer(config.Hostname, config.BindAddr, cert)
if err != nil {
fmt.Fprintf(os.Stderr, "server error: %s\n", err)
os.Exit(1)
}
// Termination signal handling (SIGINT or SIGTERM)
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigs
fmt.Println(sig)
os.Exit(2)
}()
log.Printf("Serving %s on %s\n", config.Hostname, config.BindAddr)
if err := server.Loop(geminiHandle); err != nil {
fmt.Fprintf(os.Stderr, "server loop error: %s\n", err)
os.Exit(5)
}
}
func geminiHandle(path string, conn io.ReadWriteCloser) error {
log.Printf("GET %s", path)
if f, err := os.OpenFile(config.ContentDir+"/"+path, os.O_RDONLY, 0400); err != nil {
if err == os.ErrNotExist {
log.Printf("ERR: File not found: %s", path)
return SendResponse(conn, StatusPermanentFailure, "Resource not found")
} else {
log.Printf("ERR: File error: %s", err)
return SendResponse(conn, StatusPermanentFailure, "Resource error")
}
} else {
defer f.Close()
// TODO: Replace by proper stream handling. For now it's good because I only serve small files
content, err := ioutil.ReadAll(f)
if err != nil {
log.Printf("ERR: File read error: %s", err)
return SendResponse(conn, StatusPermanentFailure, "Resource error")
}
// Get MIME
var mime string
if strings.HasSuffix(path, ".gmi") {
mime = "text/gemini; lang=en; charset=utf-8"
} else {
mime = http.DetectContentType(content)
}
return SendContent(conn, content, mime)
}
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module orion/m/v2
go 1.17

15
orion.conf Normal file
View file

@ -0,0 +1,15 @@
## orion configuration file example
## Please modify this file to your needs
## lines starting with a '#' are comments and will be ignored
# Server hostname and listen address
# Bind ':1965' will bind to any IP address and port 1965
Hostname = localhost
Bind = :1965
# TLS certificate
Certfile = orion.crt
Keyfile = orion.key
# Content directory
ContentDir = ./gemini/