First working prototype
This commit contains the first working prototype.
This commit is contained in:
parent
4090c8d4d1
commit
39940039b9
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -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
10
Makefile
Normal 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
62
cmd/orion/config.go
Normal 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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
15
orion.conf
Normal file
15
orion.conf
Normal 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/
|
Loading…
Reference in a new issue