From c516b40d74684ca6523b1ece939229958d6ce598 Mon Sep 17 00:00:00 2001 From: Steve Date: Thu, 14 May 2020 18:15:44 -0400 Subject: [PATCH] Handle client certs and authorized certs, fix CGI a bit --- Makefile | 4 +- src/gemini.go | 190 ++++++++++++++++++++++++++++++++++++++ src/gopher.go | 39 ++++++++ src/main.go | 64 +++++++++++++ src/server.go | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 src/gemini.go create mode 100644 src/gopher.go create mode 100644 src/main.go create mode 100644 src/server.go diff --git a/Makefile b/Makefile index d113f12..06c0c18 100644 --- a/Makefile +++ b/Makefile @@ -9,9 +9,9 @@ GOSRC+=go.mod go.sum RM?=rm -f secretshop: $(GOSRC) - $(GO) build $(GOFLAGS) \ + cd src && $(GO) build $(GOFLAGS) \ -ldflags "-s -w" \ - -o $@ + -o ../$@ all: secretshop diff --git a/src/gemini.go b/src/gemini.go new file mode 100644 index 0000000..efbd110 --- /dev/null +++ b/src/gemini.go @@ -0,0 +1,190 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/md5" + "crypto/x509" + "fmt" + "io/ioutil" + "log" + "mime" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// Yoinked from jetforce and go'ified +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 Response struct { + Status int + Meta string + Body string +} + +type GeminiConfig struct { + Hostname string + KeyFile string + CertFile string + RootDir string + CGIDir string + AccessControl CapsuleAcl +} + +type CapsuleAcl struct { + Identified []string + Known []string + Trusted []string + Whitelist string +} + +func (c *GeminiConfig) String() string { + return fmt.Sprintf("Gemini Config: %v Files:%v CGI:%v", c.Hostname, c.RootDir, c.CGIDir) +} + +func generateFile(selector string) Response { + meta := mime.TypeByExtension(filepath.Ext(selector)) + if meta == "" { + //assume plain UTF-8 text + meta = "text/gemini; charset=utf-8" + } + 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 generateDirectory(path string) Response { + var dirpage string + files, err := ioutil.ReadDir(path) + if err != nil { + log.Println(err) + return Response{STATUS_TEMPORARY_FAILURE, "Unable to show directory dirpage", ""} + } + dirpage = "# Directory Contents\r\n" + for _, file := range files { + // Don't list hidden files + if isNotWorldReadable(file) || strings.HasPrefix(file.Name(), ".") { + continue + } + if file.Name() == "index.gmi" || file.Name() == "index.gemini" { + //Found an index file, return that instead + return generateFile(path + file.Name()) + } else { + dirpage += fmt.Sprintf("=> %s %s\r\n", file.Name(), file.Name()) + } + } + return Response{STATUS_SUCCESS, "text/gemini", dirpage} +} + +func generateCGI(selector *url.URL, c *conn) Response { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) //make this customizable + defer cancel() + + cmd := exec.CommandContext(ctx, selector.Path) + + //build CGI environment + cmd.Env = []string{fmt.Sprintf("GEMINI_URL=%v", selector), + fmt.Sprintf("HOSTNAME=%v", selector.Hostname()), + fmt.Sprintf("PATH_INFO=%v", selector.Path), + fmt.Sprintf("QUERY_STRING=%v", selector.RawQuery), + fmt.Sprintf("REMOTE_ADDR=%v", c.C.RemoteAddr()), + fmt.Sprintf("REMOTE_HOST=%v", c.C.RemoteAddr()), + fmt.Sprintf("SERVER_NAME=%v", selector.Hostname()), + fmt.Sprintf("SERVER_PORT=%v", c.server.Port), + "SERVER_PROTOCOL=GEMINI", + "SERVER_SOFTWARE=secretshop/0.1.0", + } + cmdout, err := cmd.Output() + if ctx.Err() == context.DeadlineExceeded { + log.Printf("CGI %v timed out", selector) + return Response{STATUS_CGI_ERROR, "CGI process timed out", ""} + } + if err != nil { + log.Printf("Error running CGI process %v", selector) + return Response{STATUS_CGI_ERROR, "Error running CGI process", ""} + } + + header, _, err := bufio.NewReader(strings.NewReader(string(cmdout))).ReadLine() + if err != nil { + log.Printf("Error running CGI process %v", selector) + return Response{STATUS_CGI_ERROR, "Error running CGI process", ""} + } + header_s := strings.Fields(string(header)) + //make sure it has a valid status + status, err := strconv.Atoi(header_s[0]) + if err != nil { + log.Printf("Error running CGI process %v", selector) + return Response{STATUS_CGI_ERROR, "Error running CGI process", ""} + } + if status < 0 || status > 69 { + log.Printf("CGI script returned bad status %v", selector) + return Response{STATUS_CGI_ERROR, "Error running CGI process", ""} + } + return Response{status, "", string(cmdout)} +} + +func validCert(capsule GeminiConfig, c *x509.Certificate) int { + now := time.Now() + if c.NotBefore.After(now) { + return STATUS_FUTURE_CERTIFICATE_REJECTED + } else if c.NotAfter.Before(now) { + return STATUS_EXPIRED_CERTIFICATE_REJECTED + } + fingerprint := md5.Sum(c.Raw) + var buf bytes.Buffer + for i, f := range fingerprint { + if i > 0 { + fmt.Fprintf(&buf, ":") + } + fmt.Fprintf(&buf, "%02X", f) + } + b, err := ioutil.ReadFile(capsule.AccessControl.Whitelist) + if err != nil { + log.Printf("Unable to oppen whitelist file %v\n", err) + return STATUS_TEMPORARY_FAILURE + } + s := string(b) + //check whether s contains substring text + if strings.Contains(s, buf.String()) { + return 0 + } else { + return STATUS_CERTIFICATE_NOT_ACCEPTED + } +} diff --git a/src/gopher.go b/src/gopher.go new file mode 100644 index 0000000..8d36077 --- /dev/null +++ b/src/gopher.go @@ -0,0 +1,39 @@ +package main + +import ( + "fmt" + "path" + + "github.com/prologic/go-gopher" +) + +type GopherConfig struct { + Hostname string + Port string + RootDir string +} + +func (c *GopherConfig) String() string { + return fmt.Sprintf("Gopher Config: %v:%v Files:%v", c.Hostname, c.Port, c.RootDir) +} + +type indexHandler struct { + rootPath string + rootHandler gopher.Handler +} + +func (f *indexHandler) ServeGopher(w gopher.ResponseWriter, r *gopher.Request) { + upath := r.Selector + if gopher.GetItemType(f.rootPath+upath) == gopher.DIRECTORY && upath != "/" { + w.WriteItem(&gopher.Item{ + Type: gopher.DIRECTORY, + Selector: path.Dir(upath), + Description: "Go Back", + }) + } + f.rootHandler.ServeGopher(w, r) +} + +func index(root gopher.FileSystem) *indexHandler { + return &indexHandler{root.Name(), gopher.FileServer(root)} +} diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..28d1e66 --- /dev/null +++ b/src/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "log" + "sync" + + "github.com/prologic/go-gopher" + "github.com/spf13/viper" +) + +func main() { + viper.SetConfigName("config") + viper.AddConfigPath("/etc/secretshop/") + viper.AddConfigPath(".") + err := viper.ReadInConfig() + if err != nil { + log.Fatalf("Fatal error config file: %v \n", err) + } + viper.SetConfigType("yaml") + + //Load configs + active_capsules := viper.GetStringSlice("active_capsules") + active_holes := viper.GetStringSlice("active_holes") + port := viper.GetString("port") + capsule_list := make([]GeminiConfig, len(active_capsules)) + hole_list := make([]GopherConfig, len(active_holes)) + for i, c := range active_capsules { + viper.UnmarshalKey(c, &(capsule_list[i])) + log.Printf("Loading capsule %v %v", i, capsule_list[i].Hostname) + } + for i, h := range active_holes { + viper.UnmarshalKey(h, &(hole_list[i])) + log.Printf("Loading hole %v %v", i, hole_list[i].Hostname) + } + if len(capsule_list) < 1 && len(hole_list) < 1 { + log.Println("No capsules or gopherholes loaded. Shutting down.") + return + } + log.Printf("%v capsules loaded, %v gopherholes loaded", len(capsule_list), len(hole_list)) + // Intialize servers + wg := new(sync.WaitGroup) + wg.Add(1 + len(hole_list)) + + log.Printf("Starting gemini capsule") + go func(c interface{}) { + log.Fatal(ListenAndServeTLS(port, c.([]GeminiConfig))) + wg.Done() + }(capsule_list) + + for i, h := range hole_list { + log.Printf("Starting gopherhole %v %v", i, h.Hostname) + go func(h interface{}) { + hole := h.(GopherConfig) + gopher.Handle("/", index(gopher.Dir(hole.RootDir))) + server := &gopher.Server{Addr: "0.0.0.0:" + hole.Port, Hostname: hole.Hostname, Handler: nil} + log.Fatal(server.ListenAndServe()) + wg.Done() + }(h) + } + + log.Println("Done bringing up capsules and gopherholes") + log.Println("Ho ho! You found me!") + wg.Wait() +} diff --git a/src/server.go b/src/server.go new file mode 100644 index 0000000..19923a8 --- /dev/null +++ b/src/server.go @@ -0,0 +1,250 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "mime" + "net" + "net/url" + "os" + "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 + // Gemini 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 + Port string // TCP port + + HostnameToConfig map[string]GeminiConfig //FQDN hostname to Gemini Config +} + +type conn struct { + server *Server + C *tls.Conn + Cert *x509.Certificate +} + +func (s *Server) newConn(rwc *tls.Conn) *conn { + c := &conn{ + server: s, + C: rwc, + } + return c +} + +func ListenAndServeTLS(port string, cps []GeminiConfig) error { + server := &Server{ + Addr: ":" + port, + Port: port, + HostnameToConfig: make(map[string]GeminiConfig), + } + for _, c := range cps { + server.HostnameToConfig[c.Hostname] = c + } + return server.ListenAndServeTLS(cps) +} + +func (s *Server) ListenAndServeTLS(configs []GeminiConfig) error { + addr := s.Addr + mime.AddExtensionType(".gmi", "text/gemini") + mime.AddExtensionType(".gemini", "text/gemini") + if addr == "" { + addr = ":1965" + } + certs := make([]tls.Certificate, len(configs)) + for i, c := range configs { + cert, err := tls.LoadX509KeyPair(c.CertFile, c.KeyFile) + if err != nil { + log.Fatalf("Error loading certs: %s", err) + } + certs[i] = cert + } + + config := tls.Config{ + Certificates: certs, + ClientAuth: tls.RequestClientCert, + } + config.Rand = rand.Reader + + ln, err := tls.Listen("tcp", addr, &config) + if err != nil { + log.Fatalf("server: listen: %s", err) + } + + return s.Serve(ln, &config) +} + +func (s *Server) Serve(l net.Listener, conf *tls.Config) 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 + } + + rw_tls := rw.(*tls.Conn) + c := s.newConn(rw_tls) + 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 + 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, %v bytes read", err.Error(), count) + c.C.Close() + return + } + data[count] = b + count = count + 1 + } + var res Response + var req string + if len(c.C.ConnectionState().PeerCertificates) > 1 { + log.Printf("WARN: Client presented %v certificates, only using first one\n") + } + if len(c.C.ConnectionState().PeerCertificates) > 0 { + c.Cert = c.C.ConnectionState().PeerCertificates[0] + } else { + c.Cert = nil + } + if !strings.Contains(string(data), "\r\n") { + res = Response{STATUS_BAD_REQUEST, "Request too large", ""} + req = "TOO_LONG_REQUEST" + } else if !utf8.Valid(data) { + res = Response{STATUS_BAD_REQUEST, "URL contains non UTF8 charcaters", ""} + } else { + req = string(data[:count-2]) + res = c.server.ParseRequest(req, c) + } + c.sendResponse(res) + if c.Cert != nil { + log.Printf("remote=%v status=%v request='%v' bytes=%v subject='%v' issuer='%v'", c.C.RemoteAddr(), res.Status, req, len(res.Body), c.Cert.Subject, c.Cert.Issuer) + } else { + log.Printf("remote=%v status=%v request='%v' bytes=%v subject='%v' issuer='%v'", c.C.RemoteAddr(), res.Status, req, len(res.Body), "Anon", "Anon") + } + c.C.Close() +} + +func (s *Server) ParseRequest(req string, c *conn) 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 _, ok := s.HostnameToConfig[u.Hostname()]; !ok { + 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.", ""} + } + + //now check cert stuff + capsule := s.HostnameToConfig[u.Hostname()] + selector := capsule.RootDir + u.Path + for _, idpath := range capsule.AccessControl.Identified { + if strings.Contains(selector, capsule.RootDir+idpath) && c.Cert == nil { + return Response{STATUS_CLIENT_CERTIFICATE_REQUIRED, "Please provide a client certificate", ""} + } + } + for _, kpath := range capsule.AccessControl.Known { + if strings.Contains(selector, capsule.RootDir+kpath) && c.Cert == nil { + return Response{STATUS_TRANSIENT_CERTIFICATE_REQUESTED, "Please provide a client certificate", ""} + } + } + for _, tpath := range capsule.AccessControl.Trusted { + match := strings.Contains(selector, capsule.RootDir+tpath) + if match && c.Cert == nil { + return Response{STATUS_AUTHORISED_CERTIFICATE_REQUIRED, "Please provide a client certificate", ""} + } else if match && validCert(capsule, c.Cert) != 0 { + return Response{validCert(capsule, c.Cert), "Error", ""} + } + } + + 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 isNotWorldReadable(fi): + return Response{STATUS_TEMPORARY_FAILURE, "Unable to access file", ""} + case fi.IsDir(): + if strings.HasSuffix(u.Path, "/") { + return generateDirectory(selector) + } else { + return Response{STATUS_REDIRECT_PERMANENT, "gemini://" + u.Hostname() + u.Path + "/", ""} + } + default: + // it's a file + match := strings.Contains(selector, capsule.RootDir+capsule.CGIDir) + if match && fi.Mode().Perm()&0111 == 0111 { + //CGI file found + return generateCGI(u, c) + } else { + //Normal file found + return generateFile(selector) + } + } +} + +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 +} + +func isNotWorldReadable(file os.FileInfo) bool { + return uint64(file.Mode().Perm())&0444 != 0444 +}