diff --git a/README.md b/README.md index 259671a..5882420 100644 --- a/README.md +++ b/README.md @@ -46,4 +46,5 @@ Simply run "make uninstall". Either run the executable directly or use the SystemD unit file ## Caveats -Currently does not support cert-based authorization. +Currently does not support transient certificates +Only handles whitelisting for certificate authorization diff --git a/config.yaml.sample b/config.yaml.sample index 10361fa..21c4152 100644 --- a/config.yaml.sample +++ b/config.yaml.sample @@ -11,6 +11,14 @@ localhost: CGIDir: "/var/gemini/cgi" KeyFile: "localhost.key" CertFile: "localhost.crt" + AccessControl: + Identified: + - /id + Known: + - /known + Trusted: + - /private + Whitelist: "whitelist" localhost2: Hostname: "gemini.foo.bar" RootDir: "/var/gemini2" diff --git a/gemini.go b/gemini.go deleted file mode 100644 index 71281a8..0000000 --- a/gemini.go +++ /dev/null @@ -1,150 +0,0 @@ -package main - -import ( - "bufio" - "context" - "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 -} - -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)} -} diff --git a/gopher.go b/gopher.go deleted file mode 100644 index 8d36077..0000000 --- a/gopher.go +++ /dev/null @@ -1,39 +0,0 @@ -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/main.go b/main.go deleted file mode 100644 index 28d1e66..0000000 --- a/main.go +++ /dev/null @@ -1,64 +0,0 @@ -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/server.go b/server.go deleted file mode 100644 index a04933d..0000000 --- a/server.go +++ /dev/null @@ -1,217 +0,0 @@ -package main - -import ( - "bufio" - "context" - "crypto/rand" - "crypto/tls" - "fmt" - "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 - // 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 - - HostnameToRoot map[string]string //FQDN hostname to root folder - HostnameToCGI map[string]string //FQDN hostname to CGI folder -} - -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 ListenAndServeTLS(port string, cps []GeminiConfig) error { - server := &Server{Addr: ":" + port, Port: port, HostnameToRoot: make(map[string]string), HostnameToCGI: make(map[string]string)} - for _, c := range cps { - server.HostnameToRoot[c.Hostname] = c.RootDir - server.HostnameToCGI[c.Hostname] = c.CGIDir - } - 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} - 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 (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 - } - var res Response - var req string - 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) - log.Printf("%v requested %v; responded with %v %v", c.C.RemoteAddr(), req, res.Status, res.Meta) - 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 s.HostnameToRoot[u.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.HostnameToRoot[u.Hostname()] + 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 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 - matches, err := filepath.Glob(s.HostnameToCGI[u.Hostname()] + "/*") - if err != nil { - log.Printf("%v: Couldn't search for CGI: %v", u.Hostname(), err) - return Response{STATUS_TEMPORARY_FAILURE, "Error finding file", ""} - } - if matches != nil && 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 -}