From 03cd6bdfbd473dba3f3dc50a1b15e389aac5bc70 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Wed, 7 Jan 2026 23:39:53 +0000 Subject: Initial commit --- web/handler/auth.go | 89 ++++++++++++++++++++++++++++++++++++ web/handler/deploy.go | 81 +++++++++++++++++++++++++++++++++ web/handler/index.go | 34 ++++++++++++++ web/handler/instance.go | 95 ++++++++++++++++++++++++++++++++++++++ web/middleware/auth.go | 32 +++++++++++++ web/views/auth.html | 51 +++++++++++++++++++++ web/views/f_auth_error.html | 3 ++ web/views/f_deploy.html | 32 +++++++++++++ web/views/f_instance.html | 28 ++++++++++++ web/views/f_instance_result.html | 4 ++ web/views/index.html | 98 ++++++++++++++++++++++++++++++++++++++++ web/web.go | 39 ++++++++++++++++ 12 files changed, 586 insertions(+) create mode 100644 web/handler/auth.go create mode 100644 web/handler/deploy.go create mode 100644 web/handler/index.go create mode 100644 web/handler/instance.go create mode 100644 web/middleware/auth.go create mode 100644 web/views/auth.html create mode 100644 web/views/f_auth_error.html create mode 100644 web/views/f_deploy.html create mode 100644 web/views/f_instance.html create mode 100644 web/views/f_instance_result.html create mode 100644 web/views/index.html create mode 100644 web/web.go (limited to 'web') diff --git a/web/handler/auth.go b/web/handler/auth.go new file mode 100644 index 0000000..38b87b6 --- /dev/null +++ b/web/handler/auth.go @@ -0,0 +1,89 @@ +package handler + +import ( + "html/template" + "log/slog" + "net/http" + "strconv" + "time" + + "git.leonardobishop.net/instancer/pkg/session" +) + +func GetAuth(tmpl *template.Template) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tmpl.ExecuteTemplate(w, "auth.html", nil) + } +} + +func PostAuth(tmpl *template.Template, session *session.MemoryStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { + Message string + }{ + Message: "Invalid form data", + }) + return + } + + team := r.FormValue("team") + if team == "" { + tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { + Message string + }{ + Message: "No team entered", + }) + return + } + + if _, err := strconv.Atoi(team); err != nil { + tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { + Message string + }{ + Message: "Team ID must be number", + }) + return + } + + session, err := session.Create(team) + if err != nil { + slog.Error("could not create session", "cause", err) + tmpl.ExecuteTemplate(w, "f_auth_error.html", struct { + Message string + }{ + Message: "Could not create session", + }) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: session.Token, + + Path: "/", + Secure: true, + SameSite: http.SameSiteStrictMode, + HttpOnly: true, + }) + w.Header().Add("HX-Redirect", "/") + } +} + +func GetLogout(session *session.MemoryStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + //TODO expire session here + + http.SetCookie(w, &http.Cookie{ + Name: "session", + Value: "", + Expires: time.Unix(0, 0), + + Path: "/", + Secure: true, + SameSite: http.SameSiteStrictMode, + HttpOnly: true, + }) + http.Redirect(w, r, "/auth", http.StatusFound) + } +} diff --git a/web/handler/deploy.go b/web/handler/deploy.go new file mode 100644 index 0000000..24e0f95 --- /dev/null +++ b/web/handler/deploy.go @@ -0,0 +1,81 @@ +package handler + +import ( + "fmt" + "html/template" + "net/http" + + "git.leonardobishop.net/instancer/pkg/deployer" + "git.leonardobishop.net/instancer/pkg/registry" + "git.leonardobishop.net/instancer/pkg/session" +) + +func PostDeploy(tmpl *template.Template, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid form data", http.StatusBadRequest) + return + } + + challenge := r.FormValue("challenge") + if challenge == "" { + http.Error(w, "No challenge selected", http.StatusBadRequest) + return + } + + session := r.Context().Value("session").(*session.UserSession) + deployKey := dockerDeployer.StartDeploy(challenge, session.Team) + + tmpl.ExecuteTemplate(w, "f_deploy.html", struct { + DeployKey string + Challenge string + }{ + DeployKey: deployKey, + Challenge: challenge, + }) + } +} + +func DeploySSE(tmpl *template.Template, registryClient *registry.RegistryClient, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + + deployKey := r.URL.Query().Get("deploy") + if deployKey == "" { + http.Error(w, "No deployment specified", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + clientGone := r.Context().Done() + job := dockerDeployer.GetJob(deployKey) + + if job == nil || job.DeployChan == nil { + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", "close", "Invalid deployment key") + flusher.Flush() + return + } + + for { + select { + case <-clientGone: + return + case update, ok := <-job.DeployChan: + if !ok { + return + } + + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", update.Status, update.Message) + flusher.Flush() + } + } + + } +} diff --git a/web/handler/index.go b/web/handler/index.go new file mode 100644 index 0000000..5cf44cf --- /dev/null +++ b/web/handler/index.go @@ -0,0 +1,34 @@ +package handler + +import ( + "html/template" + "log" + "net/http" + + "git.leonardobishop.net/instancer/pkg/registry" + "git.leonardobishop.net/instancer/pkg/session" +) + +func GetIndex(tmpl *template.Template, registryClient *registry.RegistryClient) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + challenges, err := registryClient.ListRepositories() + if err != nil { + log.Printf("Could not list repositories: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + session := r.Context().Value("session").(*session.UserSession) + + if err := tmpl.ExecuteTemplate(w, "index.html", struct { + Challenges []string + Team string + }{ + Challenges: challenges, + Team: session.Team, + }); err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } +} diff --git a/web/handler/instance.go b/web/handler/instance.go new file mode 100644 index 0000000..0222a42 --- /dev/null +++ b/web/handler/instance.go @@ -0,0 +1,95 @@ +package handler + +import ( + "fmt" + "html/template" + "log/slog" + "net/http" + "time" + + "git.leonardobishop.net/instancer/pkg/deployer" + "git.leonardobishop.net/instancer/pkg/session" +) + +func GetInstances(tmpl *template.Template, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session.UserSession) + + instances, err := dockerDeployer.GetTeamInstances(r.Context(), session.Team) + if err != nil { + slog.Error("could not get team instances", "team", session.Team, "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + type instanceView struct { + ChallengeName string + DeployKey string + Address template.HTML + ExpiresIn string + } + var instanceViews []instanceView + for _, instance := range instances { + var expiresIn string + timeLeft := time.Until(instance.ExpiresAt) + if timeLeft <= 0 { + expiresIn = "now" + } else { + expiresIn = fmt.Sprintf("%dm %ds", int(timeLeft.Minutes()), int(timeLeft.Seconds())%60) + } + + var address string + + if instance.AddressFormat == "http" || instance.AddressFormat == "https" { + address = `` + instance.Address + `` + } else { + address = `` + instance.Address + `` + } + + instanceViews = append(instanceViews, instanceView{ + ChallengeName: instance.ChallengeName, + DeployKey: instance.DeployKey, + Address: template.HTML(address), + ExpiresIn: expiresIn, + }) + } + + if err := tmpl.ExecuteTemplate(w, "f_instance.html", struct { + Instances []instanceView + }{ + Instances: instanceViews, + }); err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } +} + +func PostStopInstance(tmpl *template.Template, dockerDeployer *deployer.DockerDeployer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session.UserSession) + deployKey := r.PathValue("deployKey") + + err := dockerDeployer.StopInstance(r.Context(), deployKey, session.Team) + if err != nil { + tmpl.ExecuteTemplate(w, "f_instance_result.html", struct { + State string + Message string + }{ + State: "danger", + Message: "Could not stop instance" + err.Error(), + }) + return + } + + w.Header().Set("HX-Trigger", "poll-instances-now") + + tmpl.ExecuteTemplate(w, "f_deploy_result.html", struct { + State string + Message string + }{ + State: "success", + Message: "Instance " + deployKey + " stopped", + }) + } +} diff --git a/web/middleware/auth.go b/web/middleware/auth.go new file mode 100644 index 0000000..fcba3b7 --- /dev/null +++ b/web/middleware/auth.go @@ -0,0 +1,32 @@ +package middleware + +import ( + "context" + "net/http" + + "git.leonardobishop.net/instancer/pkg/session" +) + +func MustAuthenticate(store *session.MemoryStore) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sessionCookie, err := r.Cookie("session") + if err != nil { + w.Header().Add("HX-Redirect", "/auth") + http.Redirect(w, r, "/auth", http.StatusFound) + return + } + + s := store.GetByToken(sessionCookie.Value) + if s == nil { + w.Header().Add("HX-Redirect", "/auth") + http.Redirect(w, r, "/auth", http.StatusFound) + return + } + + ctx := context.WithValue(r.Context(), "session", s) + + next(w, r.WithContext(ctx)) + } + } +} diff --git a/web/views/auth.html b/web/views/auth.html new file mode 100644 index 0000000..995815a --- /dev/null +++ b/web/views/auth.html @@ -0,0 +1,51 @@ + + + + + + Challenge Instancer + + + + + + + + + +
+
+
+
+
+

Enter a team ID

+ +
+ +
+
+ ID + + + +
+
+
+
+ +
+
+

+ Attacking this platform is out of scope of the CTF and is forbidden. + If there are any issues, please speak to an organiser. +

+
+
+
+
+
+ + + diff --git a/web/views/f_auth_error.html b/web/views/f_auth_error.html new file mode 100644 index 0000000..4ebdac3 --- /dev/null +++ b/web/views/f_auth_error.html @@ -0,0 +1,3 @@ + diff --git a/web/views/f_deploy.html b/web/views/f_deploy.html new file mode 100644 index 0000000..52edd5f --- /dev/null +++ b/web/views/f_deploy.html @@ -0,0 +1,32 @@ +
+ +
+ +
+
Deploying {{.Challenge}}
+ +
+ +
+
+
+
+
+ Deployment requested +
+
+ +
+ +
Instance {{.DeployKey}}
+
+ + +
+
diff --git a/web/views/f_instance.html b/web/views/f_instance.html new file mode 100644 index 0000000..1518ee2 --- /dev/null +++ b/web/views/f_instance.html @@ -0,0 +1,28 @@ +{{range .Instances}} + + +
+ {{.Address}} + + Expires in {{.ExpiresIn}} + + Instance {{.DeployKey}} of {{.ChallengeName}} +
+ + + + + +{{else}} + + +Your team does not have any instances + + +{{end}} diff --git a/web/views/f_instance_result.html b/web/views/f_instance_result.html new file mode 100644 index 0000000..85bd4eb --- /dev/null +++ b/web/views/f_instance_result.html @@ -0,0 +1,4 @@ + diff --git a/web/views/index.html b/web/views/index.html new file mode 100644 index 0000000..97edc88 --- /dev/null +++ b/web/views/index.html @@ -0,0 +1,98 @@ + + + + + Challenge Instancer + + + + + + + + + +
+
+
+
+
+

Deploy challenge

+ +
+
+ Challenge + + +
+
+
+
+ +
+
+
+
Instances
+ Refreshing +
+ +
+ + + + + + + + + + + +
InstanceControls
+
+
+ +
+ +
+
+
+
+
What is this?
+

+ This platform allows you to spawn an instance of a challenge for your team. + Each instance is shared across your team and can be stopped at any time. +

+

+ Instances will automatically stop after a while; if more time is needed then + you can stop the instance and deploy a new one. +

+

+ Attacking this platform is out of scope of the CTF and is forbidden. + If there are any issues, please speak to an organiser. +

+
+
+
+ + Logged in as {{.Team}}. + Not you? + +
+
+
+
+ + + diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..eaf03e8 --- /dev/null +++ b/web/web.go @@ -0,0 +1,39 @@ +package web + +import ( + "embed" + "html/template" + "net/http" + + "git.leonardobishop.net/instancer/pkg/deployer" + "git.leonardobishop.net/instancer/pkg/registry" + "git.leonardobishop.net/instancer/pkg/session" + "git.leonardobishop.net/instancer/web/handler" + "git.leonardobishop.net/instancer/web/middleware" +) + +//go:embed views +var views embed.FS + +func NewMux(registryClient *registry.RegistryClient, dockerDeployer *deployer.DockerDeployer) *http.ServeMux { + tmpl, err := template.ParseFS(views, "views/*.html") + if err != nil { + panic(err) + } + + mux := http.NewServeMux() + store := session.NewMemoryStore() + mustAuthenticate := middleware.MustAuthenticate(store) + + mux.HandleFunc("GET /auth", handler.GetAuth(tmpl)) + mux.HandleFunc("POST /auth", handler.PostAuth(tmpl, store)) + mux.HandleFunc("GET /logout", handler.GetLogout(store)) + + mux.HandleFunc("GET /", mustAuthenticate(handler.GetIndex(tmpl, registryClient))) + mux.HandleFunc("POST /deploy", mustAuthenticate(handler.PostDeploy(tmpl, dockerDeployer))) + mux.HandleFunc("GET /deploy/stream", mustAuthenticate(handler.DeploySSE(tmpl, registryClient, dockerDeployer))) + mux.HandleFunc("GET /instances", mustAuthenticate(handler.GetInstances(tmpl, dockerDeployer))) + mux.HandleFunc("POST /instances/{deployKey}/stop", mustAuthenticate(handler.PostStopInstance(tmpl, dockerDeployer))) + + return mux +} -- cgit v1.2.3-70-g09d2