commit 4103e14aec97e95f3058b5070638ec1431bca780 Author: Steve Date: Wed Feb 12 20:13:19 2020 -0500 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11d811f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.crt +*.key +*.yaml +*.sum diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..10ac961 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module gemineye + +go 1.13 + +require ( + github.com/chewxy/sexp v0.0.0-20181223234510-461851156c0f // indirect + github.com/gorilla/mux v1.7.4 // indirect + github.com/pelletier/go-toml v1.6.0 // indirect + github.com/spf13/afero v1.2.2 // indirect + github.com/spf13/cast v1.3.1 // indirect + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/viper v1.6.2 + github.com/stryan/go-gopher v0.0.0-20191008152201-adba90945dcb + golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4 // indirect + golang.org/x/text v0.3.2 // indirect + gopkg.in/ini.v1 v1.52.0 // indirect + gopkg.in/yaml.v2 v2.2.8 // indirect +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..f19cd11 --- /dev/null +++ b/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "log" + + "github.com/spf13/viper" +) + +func main() { + viper.SetConfigName("config") + viper.AddConfigPath("/etc/gemineye/") + viper.AddConfigPath(".") + err := viper.ReadInConfig() + if err != nil { // Handle errors reading the config file + log.Fatalf("Fatal error config file: %v \n", err) + } + viper.SetConfigType("yaml") + + //Load config + active_capsules := viper.GetStringSlice("active_capsules") + capsule_list := make([]Config, len(active_capsules)) + for i, c := range active_capsules { + viper.UnmarshalKey(c, &(capsule_list[i])) + } + if len(capsule_list) < 1 { + log.Println("No capsules defined. Shutting down.") + return + } + log.Fatal(ListenAndServeTLS(capsule_list[0])) +} + +type Config struct { + Hostname string + Port string + KeyFile string + CertFile string + RootDir string + CGIDir string +} + +func (c *Config) String() string { + return fmt.Sprintf("Config: %v:%v Files:%v CGI:%v", c.Hostname, c.Port, c.RootDir, c.CGIDir) +} diff --git a/proto.go b/proto.go new file mode 100644 index 0000000..ee1d483 --- /dev/null +++ b/proto.go @@ -0,0 +1,45 @@ +package main + +const ( + STATUS_INPUT = 10 + + STATUS_SUCCESS = 20 + STATUS_SUCCESS_END_OF_SESSION = 21 + + STATUS_REDIRECT_TEMPORARY = 30 + STATUS_REDIRECT_PERMANENT = 31 + + STATUS_TEMPORARY_FAILURE = 40 + STATUS_SERVER_UNAVAILABLE = 41 + STATUS_CGI_ERROR = 42 + STATUS_PROXY_ERROR = 43 + STATUS_SLOW_DOWN = 44 + + STATUS_PERMANENT_FAILURE = 50 + STATUS_NOT_FOUND = 51 + STATUS_GONE = 52 + STATUS_PROXY_REQUEST_REFUSED = 53 + STATUS_BAD_REQUEST = 59 + + STATUS_CLIENT_CERTIFICATE_REQUIRED = 60 + STATUS_TRANSIENT_CERTIFICATE_REQUESTED = 61 + STATUS_AUTHORISED_CERTIFICATE_REQUIRED = 62 + STATUS_CERTIFICATE_NOT_ACCEPTED = 63 + STATUS_FUTURE_CERTIFICATE_REJECTED = 64 + STATUS_EXPIRED_CERTIFICATE_REJECTED = 65 +) + +type Request struct { + Hostname string + Port string + Path string + Params string + Query string + Fragment string +} + +type Response struct { + Status int + Meta string + Body string +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..756dbdb --- /dev/null +++ b/server.go @@ -0,0 +1,232 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/tls" + "fmt" + "io/ioutil" + "log" + "mime" + "net" + "net/url" + "os" + "path/filepath" + "strings" + "unicode/utf8" +) + +type contextKey struct { + name string +} + +func (k *contextKey) String() string { + return "gemini context value " + k.name +} + +var ( + // ServerContextKey is a context key. It can be used in Gemini + // handlers with context.WithValue to access the server that + // started the handler. The associated value will be of type *Server. + ServerContextKey = &contextKey{"gemini-server"} + + // LocalAddrContextKey is a context key. It can be used in + // Gopher handlers with context.WithValue to access the address + // the local address the connection arrived on. + // The associated value will be of type net.Addr. + LocalAddrContextKey = &contextKey{"local-addr"} +) + +type Server struct { + Addr string // TCP address to listen on, ":gemini" if empty + + Hostname string // FQDN Hostname to reach this server on + ServerRoot string //Root folder for gemini files +} + +type conn struct { + server *Server + C net.Conn + tlsState *tls.ConnectionState +} + +func (s *Server) newConn(rwc net.Conn) *conn { + c := &conn{ + server: s, + C: rwc, + tlsState: nil, + } + return c +} +func (s *Server) ListenAndServeTLS(certFile, keyFile string) error { + addr := s.Addr + mime.AddExtensionType(".gmi", "text/gemini") + mime.AddExtensionType(".gemini", "text/gemini") + if addr == "" { + addr = ":1965" + } + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + log.Fatalf("server: loadkeys: %s", err) + } + config := tls.Config{Certificates: []tls.Certificate{cert}} + config.Rand = rand.Reader + + ln, err := tls.Listen("tcp", addr, &config) + if err != nil { + log.Fatalf("server: listen: %s", err) + } + + return s.Serve(ln) +} +func ListenAndServeTLS(cp Config) error { + server := &Server{Addr: ":" + cp.Port, ServerRoot: cp.RootDir, Hostname: cp.Hostname} + return server.ListenAndServeTLS(cp.CertFile, cp.KeyFile) +} + +func (s *Server) Serve(l net.Listener) error { + defer l.Close() + + ctx := context.Background() + ctx = context.WithValue(ctx, ServerContextKey, s) + ctx = context.WithValue(ctx, LocalAddrContextKey, l.Addr()) + + for { + rw, err := l.Accept() + if err != nil { + fmt.Errorf("error accepting new client: %s", err) + return err + } + + c := s.newConn(rw) + go c.serve(ctx) + } +} + +func (c *conn) serve(ctx context.Context) { + buf := bufio.NewReader(c.C) + data := make([]byte, 1026) //1024 for the URL, 2 for the CRLF + //req, overflow, err := req_buf.ReadLine() + count := 0 + for count < 1026 { + if strings.Contains(string(data), "\r\n") { + break + } + b, err := buf.ReadByte() + if err != nil { + log.Printf("WARN: Couldn't serve request: %v", err.Error()) + c.C.Close() + return + } + data[count] = b + count = count + 1 + } + if !strings.Contains(string(data), "\r\n") { + c.sendResponse(Response{STATUS_BAD_REQUEST, "Request too large", ""}) + c.C.Close() + return + } + if !utf8.Valid(data) { + c.sendResponse(Response{STATUS_BAD_REQUEST, "URL contains non UTF8 charcaters", ""}) + c.C.Close() + return + } + req := string(data[:count-2]) + res := c.server.ParseRequest(req) + c.sendResponse(res) + c.C.Close() +} + +func (s *Server) ParseRequest(req string) Response { + u, err := url.Parse(req) + if err != nil { + return Response{STATUS_BAD_REQUEST, "URL invalid", ""} + } + if u.Scheme == "" { + u.Scheme = "gemini" + } else if u.Scheme != "gemini" { + return Response{STATUS_PROXY_REQUEST_REFUSED, "Proxying by Scheme not currently supported", ""} + } + if u.Port() != "1965" && u.Port() != "" { + return Response{STATUS_PROXY_REQUEST_REFUSED, "Proxying by Port not currently supported", ""} + } + if u.Host == "" { + return Response{STATUS_BAD_REQUEST, "Need to specify a host", ""} + } else if u.Hostname() != s.Hostname { + return Response{STATUS_PROXY_REQUEST_REFUSED, "Proxying by Hostname not currently supported", ""} + } + if strings.Contains(u.Path, "..") { + return Response{STATUS_PERMANENT_FAILURE, "Dots in path, assuming bad faith.", ""} + } + + selector := s.ServerRoot + u.Path + fi, err := os.Stat(selector) + switch { + case err != nil: + // File doesn't exist. + return Response{STATUS_NOT_FOUND, "Couldn't find file", ""} + case os.IsNotExist(err) || os.IsPermission(err): + return Response{STATUS_NOT_FOUND, "File does not exist", ""} + case uint64(fi.Mode().Perm())&0444 != 0444: + return Response{STATUS_TEMPORARY_FAILURE, "Unable to access file", ""} + case fi.IsDir(): + if strings.HasSuffix(u.Path, "/") { + return generateDirectoryListing(selector) + } else { + return Response{STATUS_REDIRECT_PERMANENT, "gemini://" + s.Hostname + u.Path + "/", ""} + } + default: + // it's a file + meta := mime.TypeByExtension(filepath.Ext(selector)) + file, err := os.Open(selector) + if err != nil { + panic("Failed to read file") + } + defer file.Close() + buf, err := ioutil.ReadAll(file) + return Response{STATUS_SUCCESS, meta, string(buf)} + } +} + +func generateDirectoryListing(path string) Response { + var listing string + files, err := ioutil.ReadDir(path) + if err != nil { + log.Println(err) + return Response{STATUS_TEMPORARY_FAILURE, "Unable to show directory listing", ""} + } + listing = "# Directory listing\r\n" + for _, file := range files { + // Skip dotfiles + if strings.HasPrefix(file.Name(), ".") { + continue + } + // Only list world readable files + if uint64(file.Mode().Perm())&0444 != 0444 { + continue + } + if file.Name() == "index.gmi" || file.Name() == "index.gemini" { + //Found an index file, return that instead + read_file, err := os.Open(path + file.Name()) + if err != nil { + return Response{STATUS_TEMPORARY_FAILURE, "Unable to show directory index file", ""} + } + defer read_file.Close() + buf, err := ioutil.ReadAll(read_file) + return Response{STATUS_SUCCESS, "text/gemini", string(buf)} + } else { + listing += fmt.Sprintf("=> %s %s\r\n", file.Name(), file.Name()) + } + } + return Response{STATUS_SUCCESS, "text/gemini", listing} +} + +func (c *conn) sendResponse(r Response) error { + c.C.Write([]byte(fmt.Sprintf("%v %v\r\n", r.Status, r.Meta))) + if r.Body != "" { + c.C.Write([]byte(r.Body)) + } + return nil +}