Massive cleanups on http frontend aspects

This commit is contained in:
2023-10-25 23:49:47 +02:00
parent b934270d14
commit 3d5fa80fc9
11 changed files with 412 additions and 114 deletions

36
app/ipasso/envEndpoint.go Normal file
View File

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

View File

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

68
app/ipasso/loginKrb.go Normal file
View File

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

View File

@@ -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(`
<!DOCTYPE html>
<html>
<head>
<style>
pre { margin: 0 1ex; }
</style>
</head>
<body>
<h1>Request to:</h1>
<pre>{{ .ReqURL }}</pre>
<h1>Headers</h1>
<table>
{{- range $k,$v := .Headers }}
<tr>
<td><pre>[{{ $k }}]</pre></td>
<td><pre>{{ $v }}</pre></td>
</tr>
{{- end }}
</table>
<h1>Process Environment</h1>
<table>
{{- range $k,$v := .ProcessEnv }}
<tr>
<td><pre>{{ $k }}</pre></td>
<td><pre>{{ $v }}</pre></td>
</tr>
{{- end }}
</table>
</body>
</html>
`))
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)
}
}

6
go.mod
View File

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

8
go.sum
View File

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

146
resources/resources.go Normal file
View File

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

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<title>IpaSSO</title>
</head>
<body>
<li>
<a href="/login/krb5">KRB login</a>
<a href="/env">Dump environment</a>
<a href="/env/krb5">Dump GSSAPI environment</a>
</li>
</body>
</html>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<style>
pre { margin: 0 1ex; }
</style>
</head>
<body>
<h1>Request to:</h1>
<pre>{{ .ReqURL }}</pre>
<h1>Headers</h1>
<table>
{{- range k,v := .Headers }}
<tr>
<td><pre>{{ k }}</pre></td>
<td><pre>{{ v }}</pre></td>
</tr>
{{- end }}
</table>
<h1>Process Environment</h1>
<table>
{{- range k,v := .ProcessEnv }}
<tr>
<td><pre>{{ k }}</pre></td>
<td><pre>{{ v }}</pre></td>
</tr>
{{- end }}
</table>
</body>
</html>

View File

@@ -0,0 +1,9 @@
Request: {{ .ReqURL }}
ProccessEnv:
{{- range k,v := .ProcessEnv }}
{{ k }}: {{ v }}
{{- end }}
headers:
{{- range k,v := .Headers }}
{{ k }}: {{ v }}
{{- end }}

View File

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