diff --git a/app/ipasso/envEndpoint.go b/app/ipasso/envEndpoint.go new file mode 100644 index 0000000..4b8783c --- /dev/null +++ b/app/ipasso/envEndpoint.go @@ -0,0 +1,36 @@ +package main + +import ( + "git.thequux.com/thequux/ipasso/util/startup" + "github.com/julienschmidt/httprouter" + "go.uber.org/zap" + "net/http" + "net/http/fcgi" + "strings" +) + +var templateLogger *zap.Logger + +func init() { + startup.Routes.Add(func(router *httprouter.Router) { + router.HandlerFunc("GET", "/env/*rest", dumpEnv) + }) + startup.Logger.Add(func() { + templateLogger = zap.L().Named("template") + }) +} + +func dumpEnv(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + + var headers = map[string]string{} + for k, v := range r.Header { + headers[k] = strings.Join(v, "\n") + } + var args = envdocArgs{ + ProcessEnv: fcgi.ProcessEnv(r), + Headers: headers, + ReqURL: r.URL.String(), + } + RenderPage(w, r, "env", args) +} diff --git a/app/ipasso/userdata.go b/app/ipasso/ldap.go similarity index 98% rename from app/ipasso/userdata.go rename to app/ipasso/ldap.go index 183741a..143723c 100644 --- a/app/ipasso/userdata.go +++ b/app/ipasso/ldap.go @@ -92,14 +92,15 @@ func init() { ldapRootLogger.Debug("Configured local kerberos principal", zap.String("principal", *krb5Principal)) } - gssapiClient, err = gssapi.NewClientWithKeytab(*krb5Principal, *krb5realm, *keytab, *krb5conf) + gssapiClient, err := gssapi.NewClientWithKeytab(*krb5Principal, *krb5realm, *keytab, *krb5conf) if err != nil { ldapRootLogger.Fatal("Failed to initialize kerberos", zap.Error(err)) } // Create the LDAP pool ldapPool = genpool.NewPool[ldap.Conn](&ldapPoolManager{gssapiClient: gssapiClient}, 5) - + }) + startup.Startup.Add(func() { // Test the pool... conn, err := ldapPool.Get() if err != nil { diff --git a/app/ipasso/loginKrb.go b/app/ipasso/loginKrb.go new file mode 100644 index 0000000..c9c3c25 --- /dev/null +++ b/app/ipasso/loginKrb.go @@ -0,0 +1,68 @@ +package main + +import ( + "git.thequux.com/thequux/ipasso/sso-proxy/backend" + "git.thequux.com/thequux/ipasso/util/startup" + "github.com/julienschmidt/httprouter" + "log" + "net/http" + "net/http/fcgi" + "strconv" + "strings" + "time" +) + +func init() { + startup.Routes.Add(func(router *httprouter.Router) { + router.HandlerFunc("POST", "/login/krb5", loginKrb) + }) +} + +func loginKrb(w http.ResponseWriter, r *http.Request) { + // Uses information from the process environment; assumes that Apache has done a krb5 login + // The fact that we got here implies that the login succeeded + env := fcgi.ProcessEnv(r) + user := env["GSS_NAME"] + uid := strings.Split(user, "@")[0] + expirationTxt, hasExpiration := env["GSS_SESSION_EXPIRATION"] + var expiration time.Time + if hasExpiration { + expInt, err := strconv.ParseInt(expirationTxt, 10, 64) + if err != nil { + // Invalid expiration date; bail + log.Printf("Invalid expiration date: %#v", expirationTxt) + w.WriteHeader(http.StatusInternalServerError) + } else { + expiration = time.Unix(expInt, 0) + } + } else { + log.Print(ErrNoExpiration) + ReportError(w, ErrNoExpiration) + return + } + + SessionID, err := datastore.NewSessionID() + if err != nil { + ReportError(w, err) + return + } + + entry, err := getUserByPrincipal(user) + if err != nil { + ReportError(w, err) + return + } + + session := backend.Session{ + SessionID: SessionID, + Expiration: expiration, + UserID: uid, + LdapDN: entry.DN, + } + sessionCache, err := buildSessionCache(&session, entry) + if err != nil { + ReportError(w, err) + return + } + _ = datastore.PutSession(session, sessionCache) +} diff --git a/app/ipasso/main.go b/app/ipasso/main.go index 4371a04..07451de 100644 --- a/app/ipasso/main.go +++ b/app/ipasso/main.go @@ -1,22 +1,24 @@ package main import ( + "bytes" "encoding/json" "errors" "flag" "fmt" + "git.thequux.com/thequux/ipasso/resources" "git.thequux.com/thequux/ipasso/sso-proxy/backend" "git.thequux.com/thequux/ipasso/util/startup" + "github.com/CloudyKit/jet/v6" + "github.com/julienschmidt/httprouter" + "gitlab.com/jamietanna/content-negotiation-go" "go.uber.org/zap" - htemplate "html/template" "log" "net" "net/http" "net/http/fcgi" "os" "strconv" - "strings" - "time" ) var ( @@ -43,52 +45,20 @@ func main() { startup.Logger.Run() startup.PostFlags.Run() + startup.Startup.Run() + + router := httprouter.New() + startup.Routes.Run(router) - mux := http.NewServeMux() - mux.Handle("/env/", http.HandlerFunc(dumpEnv)) - mux.Handle("/login/krb5", http.HandlerFunc(loginKrb)) l.Info("Starting") listener, err := net.Listen("tcp", *listen) if err != nil { log.Fatalln("Failed to listen: ", err) } l.Info("Listening", zap.Stringer("addr", listener.Addr())) - log.Fatal(fcgi.Serve(listener, mux)) + log.Fatal(fcgi.Serve(listener, router)) } -var envTemplate = htemplate.Must(htemplate.New("envdoc").Parse(` - - - - - - -

Request to:

-
{{ .ReqURL }}
-

Headers

- - {{- range $k,$v := .Headers }} - - - - - {{- end }} -
[{{ $k }}]
{{ $v }}
-

Process Environment

- - {{- range $k,$v := .ProcessEnv }} - - - - - {{- end }} -
{{ $k }}
{{ $v }}
- - -`)) - type envdocArgs struct { Headers map[string]string ProcessEnv map[string]string @@ -110,72 +80,6 @@ func continueNegotate(w http.ResponseWriter, r *http.Request) bool { return false } -func dumpEnv(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") - - var headers = map[string]string{} - for k, v := range r.Header { - headers[k] = strings.Join(v, "\n") - } - var args = envdocArgs{ - ProcessEnv: fcgi.ProcessEnv(r), - Headers: headers, - ReqURL: r.URL.String(), - } - if err := envTemplate.Execute(w, args); err != nil { - panic(err) - } -} - -func loginKrb(w http.ResponseWriter, r *http.Request) { - // Uses information from the process environment; assumes that Apache has done a krb5 login - // The fact that we got here implies that the login succeeded - env := fcgi.ProcessEnv(r) - user := env["GSS_NAME"] - uid := strings.Split(user, "@")[0] - expirationTxt, hasExpiration := env["GSS_SESSION_EXPIRATION"] - var expiration time.Time - if hasExpiration { - expInt, err := strconv.ParseInt(expirationTxt, 10, 64) - if err != nil { - // Invalid expiration date; bail - log.Printf("Invalid expiration date: %#v", expirationTxt) - w.WriteHeader(http.StatusInternalServerError) - } else { - expiration = time.Unix(expInt, 0) - } - } else { - log.Print(ErrNoExpiration) - ReportError(w, ErrNoExpiration) - return - } - - SessionID, err := datastore.NewSessionID() - if err != nil { - ReportError(w, err) - return - } - - entry, err := getUserByPrincipal(user) - if err != nil { - ReportError(w, err) - return - } - - session := backend.Session{ - SessionID: SessionID, - Expiration: expiration, - UserID: uid, - LdapDN: entry.DN, - } - sessionCache, err := buildSessionCache(&session, entry) - if err != nil { - ReportError(w, err) - return - } - _ = datastore.PutSession(session, sessionCache) -} - type RespErr struct { Err string Status string @@ -190,3 +94,53 @@ func ReportError(w http.ResponseWriter, err error) { } _, _ = w.Write(js) } + +var negotiator = contentnegotiation.NewNegotiator("text/html", "text/plain", "application/json") + +func RenderPage(w http.ResponseWriter, req *http.Request, template string, data interface{}) { + ctype, _, err := negotiator.Negotiate(req.Header.Get("Accept")) + if err != nil { + templateLogger.Warn("Negotiation failed", + zap.String("accept", req.Header.Get("Accept")), + zap.Error(err), + ) + ReportError(w, err) + } + + w.Header().Set("Content-Type", ctype.String()) + + var result []byte + var jetTemplate *jet.Template + + if ctype.String() == "text/html" { + template = template + ".html" + jetTemplate, err = resources.Templates.HtmlTemplate(template) + } else if ctype.String() == "text/plain" { + template = template + ".txt" + jetTemplate, err = resources.Templates.TextTemplate(template) + } else if ctype.String() == "application/json" { + jetTemplate = nil + result, err = json.MarshalIndent(data, "", " ") + } else { + templateLogger.Warn("Negotiation returned something unexpected", zap.Stringer("negotiated", &ctype)) + err = errors.New("unexpected negotiation result") + } + + if err == nil && jetTemplate != nil && len(result) == 0 { + var builder bytes.Buffer + err := jetTemplate.Execute(&builder, nil, data) + if err != nil { + templateLogger.Warn("Failed to render template", zap.String("tmpl", template), zap.Error(err)) + } else { + result = builder.Bytes() + } + } + if err == nil { + w.Header().Set("Content-Type", ctype.String()) + w.Header().Set("Content-Length", strconv.FormatInt(int64(len(result)), 10)) + w.WriteHeader(200) + _, _ = w.Write(result) + } else { + ReportError(w, err) + } +} diff --git a/go.mod b/go.mod index 712145f..3cda89e 100644 --- a/go.mod +++ b/go.mod @@ -5,13 +5,18 @@ go 1.20 replace github.com/go-ldap/ldap => ./_patch/go-ldap require ( + github.com/CloudyKit/jet/v6 v6.2.0 github.com/go-ldap/ldap v3.0.3+incompatible github.com/go-ldap/ldap/v3 v3.4.6 + github.com/jcmturner/gokrb5/v8 v8.4.4 + github.com/julienschmidt/httprouter v1.3.0 + gitlab.com/jamietanna/content-negotiation-go v0.2.0 go.uber.org/zap v1.26.0 ) require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect github.com/google/uuid v1.3.1 // indirect @@ -20,7 +25,6 @@ require ( github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect github.com/jcmturner/gofork v1.7.6 // indirect github.com/jcmturner/goidentity/v6 v6.0.1 // indirect - github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/crypto v0.13.0 // indirect diff --git a/go.sum b/go.sum index 0a5ba81..dbc15ad 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oME= +github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA= github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,6 +35,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6 github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -42,6 +48,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +gitlab.com/jamietanna/content-negotiation-go v0.2.0 h1:vT0OLEPQ6DYRG3/1F7joXSNjVQHGivJ6+JzODlJfjWw= +gitlab.com/jamietanna/content-negotiation-go v0.2.0/go.mod h1:n4ZZ8/X5TstnjYRnjEtR/fC7MCTe+aRKM7PQlLBH3PQ= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= diff --git a/resources/resources.go b/resources/resources.go new file mode 100644 index 0000000..7519446 --- /dev/null +++ b/resources/resources.go @@ -0,0 +1,146 @@ +package resources + +import ( + "embed" + "flag" + "git.thequux.com/thequux/ipasso/util/startup" + "github.com/CloudyKit/jet/v6" + "github.com/julienschmidt/httprouter" + "go.uber.org/zap" + "io" + "io/fs" + "net/http" + "os" + "path" + "strings" +) + +var ( + dumpResources = flag.Bool("extract-resources", false, "Extract resources to directory indicated by --external-resources") + externalResources = flag.String("external-resources", "", "Directory in which to find external resources") +) + +//go:embed static templates +var embedded embed.FS + +var Resources fs.FS = &embedded + +var l *zap.Logger +var Templates TemplateSource +var StaticFiles fs.FS + +func init() { + + startup.Logger.Add(func() { + l = zap.L().Named("resources") + }) + startup.PostFlags.Add(func() { + var err error + if *dumpResources { + if *externalResources == "" { + l.Fatal("Cannot extract resources without --external-resources") + } + extractResources(*externalResources) + os.Exit(0) + } + + if *externalResources != "" { + Resources = os.DirFS(*externalResources) + } + + fs.WalkDir(Resources, ".", func(path string, d fs.DirEntry, err error) error { + l.Debug("found static file", zap.String("path", path)) + return nil + }) + + templateFS, err := fs.Sub(Resources, "templates") + if err != nil { + l.Fatal("Unable to find templates") + } + + Templates = loadTemplates(templateFS, *externalResources == "") + StaticFiles, err = fs.Sub(Resources, "static") + if err != nil { + l.Fatal("Cannot find static files", zap.Error(err)) + } + }) + + startup.Routes.Add(func(router *httprouter.Router) { + router.NotFound = http.FileServer(http.FS(StaticFiles)) + }) +} + +func extractResources(dst string) { + if err := os.MkdirAll(dst, 0755); err != nil { + l.Fatal("Failed to create external resource directory", zap.Error(err)) + } + _ = fs.WalkDir(embedded, "/", func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + l.Warn("Failed to walk file", zap.String("path", filePath), zap.Error(err)) + return nil + } + fullPath := path.Join(dst, filePath) + if d.IsDir() { + if err = os.Mkdir(fullPath, 0755); err != nil { + l.Warn("Failed to extract directory", zap.String("dst", fullPath), zap.Error(err)) + return fs.SkipDir + } + } else { + data, err := fs.ReadFile(embedded, filePath) + if err == nil { + err = os.WriteFile(fullPath, data, 0644) + } + if err != nil { + l.Warn("Failed to copy file", zap.String("dst", fullPath), zap.Error(err)) + } + } + return nil + }) +} + +type TemplateSource struct { + htmlCache *jet.Set + textCache *jet.Set +} + +func loadTemplates(src fs.FS, devMode bool) TemplateSource { + var devModeOpt jet.Option + if devMode { + devModeOpt = jet.InDevelopmentMode() + } else { + devModeOpt = func(set *jet.Set) {} + } + + loader := genFsLoader{fs: src} + return TemplateSource{ + htmlCache: jet.NewSet(loader, devModeOpt), + textCache: jet.NewSet(loader, devModeOpt, jet.WithSafeWriter(nil)), + } +} + +func (ts TemplateSource) HtmlTemplate(name string) (*jet.Template, error) { + return ts.htmlCache.GetTemplate(name) +} + +func (ts TemplateSource) TextTemplate(name string) (*jet.Template, error) { + return ts.textCache.GetTemplate(name) +} + +type genFsLoader struct { + fs fs.FS +} + +func (g genFsLoader) Exists(templatePath string) bool { + templatePath = strings.TrimPrefix(templatePath, "/") + l.Debug("Probe", zap.String("path", templatePath)) + v, err := g.fs.Open(templatePath) + if err == nil { + _ = v.Close() + } + return err == nil +} + +func (g genFsLoader) Open(templatePath string) (io.ReadCloser, error) { + templatePath = strings.TrimPrefix(templatePath, "/") + return g.fs.Open(templatePath) +} diff --git a/resources/static/index.html b/resources/static/index.html new file mode 100644 index 0000000..2939acc --- /dev/null +++ b/resources/static/index.html @@ -0,0 +1,13 @@ + + + + IpaSSO + + +
  • + KRB login + Dump environment + Dump GSSAPI environment +
  • + + \ No newline at end of file diff --git a/resources/templates/env.html b/resources/templates/env.html new file mode 100644 index 0000000..aa21262 --- /dev/null +++ b/resources/templates/env.html @@ -0,0 +1,30 @@ + + + + + + +

    Request to:

    +
    {{ .ReqURL }}
    +

    Headers

    + + {{- range k,v := .Headers }} + + + + + {{- end }} +
    {{ k }}
    {{ v }}
    +

    Process Environment

    + + {{- range k,v := .ProcessEnv }} + + + + + {{- end }} +
    {{ k }}
    {{ v }}
    + + diff --git a/resources/templates/env.txt b/resources/templates/env.txt new file mode 100644 index 0000000..e6bf5e6 --- /dev/null +++ b/resources/templates/env.txt @@ -0,0 +1,9 @@ +Request: {{ .ReqURL }} +ProccessEnv: +{{- range k,v := .ProcessEnv }} + {{ k }}: {{ v }} +{{- end }} +headers: +{{- range k,v := .Headers }} + {{ k }}: {{ v }} +{{- end }} diff --git a/util/startup/startup.go b/util/startup/startup.go index 7c99b80..18e17df 100644 --- a/util/startup/startup.go +++ b/util/startup/startup.go @@ -1,17 +1,22 @@ package startup +import "github.com/julienschmidt/httprouter" + // Pre-defined queues... var ( - Logger StartupQueue - PostFlags StartupQueue + Logger Phase + // Phase + PostFlags Phase + Startup Phase + Routes ParameterizedPhase[*httprouter.Router] ) -type StartupQueue struct { +type Phase struct { items []func() hasRun bool } -func (q *StartupQueue) Add(initFn ...func()) { +func (q *Phase) Add(initFn ...func()) { if q.hasRun { panic("Added init function after startup") } @@ -20,7 +25,7 @@ func (q *StartupQueue) Add(initFn ...func()) { } } -func (q *StartupQueue) Run() { +func (q *Phase) Run() { if q.hasRun { panic("Attempted to run init function twice") } @@ -29,3 +34,27 @@ func (q *StartupQueue) Run() { fn() } } + +type ParameterizedPhase[Param interface{}] struct { + items []func(Param) + hasRun bool +} + +func (q *ParameterizedPhase[Param]) Add(initFn ...func(Param)) { + if q.hasRun { + panic("Added init function after startup") + } + for _, fn := range initFn { + q.items = append(q.items, fn) + } +} + +func (q *ParameterizedPhase[Param]) Run(param Param) { + if q.hasRun { + panic("Attempted to run init function twice") + } + q.hasRun = true + for _, fn := range q.items { + fn(param) + } +}