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 }}
-
- [{{ $k }}] |
- {{ $v }} |
-
- {{- end }}
-
- Process Environment
-
- {{- range $k,$v := .ProcessEnv }}
-
- {{ $k }} |
- {{ $v }} |
-
- {{- end }}
-
-
-
-`))
-
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 }}
+
+ {{ k }} |
+ {{ v }} |
+
+ {{- end }}
+
+ Process Environment
+
+ {{- range k,v := .ProcessEnv }}
+
+ {{ k }} |
+ {{ v }} |
+
+ {{- end }}
+
+
+
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)
+ }
+}