Handle client certs and authorized certs, fix CGI a bit

This commit is contained in:
stryan 2020-05-14 18:15:44 -04:00
parent 0be503e857
commit c516b40d74
5 changed files with 545 additions and 2 deletions

View File

@ -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

190
src/gemini.go Normal file
View File

@ -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
}
}

39
src/gopher.go Normal file
View File

@ -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)}
}

64
src/main.go Normal file
View File

@ -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()
}

250
src/server.go Normal file
View File

@ -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
}